·
Пароли в эпоху passkeys: как их хранить и как не уронить этим свой же сервис
Пароли отменяют уже который год, а хранить их все равно приходится. Разбираем соль, перец и Argon2id, реальные атаки вместо книжных, и отказ, о котором почти не пишут: когда защита паролей превращается в способ положить собственный сервис.
Пароль — странный секрет. Его вбивают куда попало, используют еще на сорока сайтах и при этом ждут, что хранить его будут вечно, не потеряют и не прочитают.
От паролей избавляются все. Apple, Google и Microsoft включают passkeys по умолчанию, FIDO Alliance десять лет строил замену. Но если вы держите систему аутентификации в 2026 году, хеши паролей вы храните до сих пор. И будете хранить еще годы.
Дальше — про то, что в этом действительно важно: как устроено хеширование, какие атаки реальны, а какие живут только в статьях, и про отказ, о котором почти не пишут. Про момент, когда защита паролей превращается в способ положить собственный сервис.
Почему нельзя просто зашифровать
Самая частая ошибка в хранении паролей — потянуться за шифрованием.
Шифрование обратимо, в этом весь смысл: есть ключ — есть исходный текст. Значит, у того, кто добрался до базы, появляется понятная следующая цель: найти ключ. Лежит он обычно на той же машине, в том же секрет-менеджере или в окружении того же процесса, который только что скомпрометировали. База паролей и ключ к ней утекают вместе — это скорее правило, чем исключение.
Хеш работает иначе. Криптографическая хеш-функция односторонняя: посчитать вперед легко, обратить вычислительно невозможно. Пароль не хранится нигде. Хранится результат функции от него, а при входе та же функция считается от введенного значения, и сравниваются уже результаты.
Отсюда следствие, которое стоит проговорить прямо: правильно сделанная аутентификация не может сообщить вам ваш же пароль. Если сервис присылает на почту текущий пароль — он его не хеширует. Это не мелочь реализации, а диагноз всей архитектуре.
Чем плохи быстрые хеши
Здесь первое поколение парольного хранения и ошиблось.
MD5 и семейство SHA — тоже криптографические хеши, тоже односторонние. Но быстрые: их делали для контрольных сумм и подписей, где скорость идет в плюс.
Для паролей скорость превращается в уязвимость.
Тому, у кого на руках слитая таблица хешей, обращать функцию не нужно. Достаточно перебирать: гонять кандидатов через ту же функцию и искать совпадения. Атака идет офлайн — ни rate limiting, ни блокировок, ни логов, ни сети. Только его железо против вашего выбора алгоритма.
А железо сейчас злое. Топовая потребительская видеокарта считает MD5 со скоростью порядка ста миллиардов хешей в секунду; SHA-256 медленнее, но это все равно десятки миллиардов. Словарь популярных паролей, слитые корпуса, словарные мутации — весь реалистичный keyspace закрывается за минуты.
Отдельная оптимизация со стороны атакующего — rainbow-таблицы, заранее посчитанные соответствия «хеш → пароль». Защита от них появилась первой, и без нее не обходится ни одна парольная система.
Соль: каждый хеш становится отдельной задачей
Соль — случайное значение, свое для каждого пароля. Лежит рядом с хешем, подмешивается перед хешированием.
Секретной она не бывает и быть не должна. Задача у нее уже и хитрее, чем обычно пишут:
- Убивает предвычисление. Rainbow-таблица, собранная на весь интернет, против вашей базы бесполезна: каждый хеш посчитан со своим случайным значением.
- Убивает пакетный перебор. Без соли атакующий ломает всех десять миллионов пользователей разом — одна попытка проверяется против каждой строки сразу. С уникальной солью каждого приходится ломать отдельно, и десять миллионов пользователей превращаются в десять миллионов независимых задач.
- Прячет совпадения. Без соли у двух пользователей с одинаковым паролем совпадет и хеш, а в дампе это видно с первого взгляда.
В Versola каждая запись пароля несет свои 16 байт из CSPRNG. База, ничего опционального.
Перец: то, чего в базе нет
Перец — второй секретный ингредиент, но, в отличие от соли, по-настоящему секретный, общий для всех записей и лежащий намеренно не в базе.
Угроза, от которой он спасает, встречается чаще прочих: атакующий получил дамп базы, но не хост приложения. SQL-инъекция, забытый бэкап, аналитик с лишними правами, реплика без пароля. Во всех случаях у него на руках таблица и больше ничего.
Соль тут не поможет, она в том же дампе. Перец поможет: без него украденные хеши не атакуются вообще. Ни один кандидат не даст совпадения, сколько железа в перебор ни вложи.
В Versola перец уходит в параметр associated data у Argon2 и берется из конфигурации приложения, но не из базы:
val params = new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withVersion(Argon2Parameters.ARGON2_VERSION_13)
.withIterations(Argon2Iterations)
.withMemoryAsKB(Argon2MemoryKiB)
.withParallelism(Argon2Parallelism)
.withSalt(salt) // своя на запись, лежит в БД
.withAdditional(pepper) // одна на деплой, в БД не попадает
.build()
OWASP аккуратно называет перец defense in depth, а не основным контролем, и правильно делает. Против того, кто владеет хостом приложения, перец не дает ничего. Но утечка одной только базы — сценарий настолько заезженный, что страховка окупается.
Memory-hardness: почему выиграл Argon2id
Соль и перец меняют экономику офлайн-перебора, но не стоимость одной попытки. За нее отвечает сама функция, и медленной она должна быть намеренно, с настраиваемым запасом.
Первое поколение таких функций, PBKDF2 и bcrypt, брало итерациями: прогнать внутреннюю функцию тысячи раз. Стоимость атаки растет линейно, какое-то время этого хватало. Потом появилось специализированное железо. GPU и FPGA прекрасно гоняют много мелких независимых вычислений параллельно, и от итераций им ни холодно ни жарко.
Идея memory-hard функций — ударить по экономике с другой стороны. Вычисления в кремнии параллелятся дешево, память — нет. Транзисторы под арифметику мелкие и мельчают дальше, а RAM громоздкая, жрет питание и стоит денег за каждый мегабайт.
Поэтому Argon2 заставляет каждое вычисление заполнять большой блок памяти и многократно к нему обращаться. Арифметика становится легкой частью, а упирается все в объем и пропускную способность памяти.
Посчитаем. При 19 МиБ на хеш в 24 ГБ видеопамяти помещается около 1290 одновременных Argon2id — и это потолок по одному лишь объему, до всякой конкуренции за шину. Та же карта, которая выдавала сто миллиардов MD5 в секунду, здесь вытягивает несколько тысяч хешей. Разница примерно в семь порядков, и берется она с того, что атакующего заставили покупать память вместо вычислений.
Argon2id — гибридный вариант: сначала обращения к памяти не зависят от данных, что закрывает side-channel атаки по таймингу доступа, потом переключается на зависимые, что закрывает time-memory tradeoff. Если нет отдельной причины взять что-то другое, берут его.
| Алгоритм | Work factor | Стойкость к GPU/ASIC | Вердикт |
|---|---|---|---|
| MD5 / SHA-256 без соли | нет | нет | Для паролей непригоден |
| PBKDF2-HMAC-SHA256 | итерации | слабая, упирается в вычисления | Сойдет, если требует комплаенс |
| bcrypt | cost factor | средняя, working set 4 КиБ | Нормально, но следите за лимитом в 72 байта |
| scrypt | N, r, p | сильная | Хорошая альтернатива |
| Argon2id | m, t, p | сильная | Выбор по умолчанию |
Базовая рекомендация OWASP для Argon2id — m=19456 (19 МиБ), t=2, p=1. Ровно это и стоит в Versola. Альтернативы оттуда же (46 МиБ при t=1, 12 МиБ при t=3 и так далее) меняют память на итерации при той же стойкости — выбирают по тому, какого ресурса не жалко.
О чем не предупреждают
Теперь поворот, ради которого статья и писалась.
Проверка пароля только что стала намеренно дорогой. Каждый вход — это 19 МиБ и десятки миллисекунд процессорного времени. Так и задумано.
Осталось понять, кто это вычисление запускает.
Эндпоинт логина неаутентифицирован по определению, до него дотягивается любой человек в интернете. Получилась машина, которая из одного дешевого HTTP-запроса делает 19 МиБ вашей памяти и десятки миллисекунд вашего процессора. Адрес машины опубликован.
Асимметрия, которая защищает вас офлайн, онлайн разворачивается на 180 градусов. Атакующий не платит ничего. Платите вы.
Арифметика неприятная. 200 попыток входа в секунду, хеш по 75 мс — в каждый момент считается около пятнадцати хешей, а это 285 МиБ резидентной памяти на одно только хеширование. Никакой изощренности тут нет: обычный всплеск трафика или средней руки прогон credential stuffing. А лимит памяти у пода — 512 МиБ.
Ломается это обычно худшим из возможных способов. Блокирующую работу такого рода большинство рантаймов раскидывает по пулу потоков, который растет по требованию. Back pressure взяться неоткуда: конкурентность растет вместе с входящим трафиком, пока не кончится heap. Дальше процесс не деградирует, а падает. Причем не только логин, а все, что он обслуживал.
Лечится это admission control — семафором с ограничением перед хешированием.
hashingSemaphore <- Semaphore.make(argon2Config.maxConcurrent.toLong)
При лимите в 12 худший случай по памяти хеширования зафиксирован на ~228 МиБ, сколько бы запросов ни пришло. Все, что сверх лимита, не падает с ошибкой и не занимает память под свой хеш — оно ждет, и на файберах само ожидание почти ничего не стоит: приостановленное продолжение занимает пару сотен байт, а не мегабайт стека, как заблокированный поток ОС.
Но очередь без своего лимита — это отдельная авария. Каждый ждущий запрос держит открытым соединение. К нему привязаны буферы, заголовки и сам файбер, и все это лежит в памяти, пока не придет разрешение или не сработает таймаут. Ограничили только хеширование — атакующий откроет соединений куда больше, чем вы способны прохешировать, поставит их все в очередь и вытеснит память под соединения, файловые дескрипторы или таблицу соединений на прокси, ни разу не вызвав Argon2id. Семафор закрыл один ресурс и оставил открытым другой.
У очереди должен быть свой явный лимит: ограничить число ждущих запросов, при превышении сразу отвечать 503 вместо того, чтобы растить очередь дальше, и поставить таймаут на ожидание, чтобы неполученное разрешение не держало соединение вечно. Только вместе — лимит конкурентности и лимит очереди — превращают перегрузку в плавную деградацию, а не в аварию: латентность растет, ограниченная очередь съедает всплеск, все, что сверх ее вместимости, отбрасывается сразу, а остальные эндпоинты продолжают отвечать.
Оба лимита считаются от реальных лимитов памяти и соединений. Это настройка деплоя, а не константа в коде: правильные значения зависят от контейнера и прокси, за которыми все крутится.
Но и вместе с лимитом очереди семафор защищает только процесс, а не сервис. Вопроса «легитимный ли это трафик вообще» он не задает. За него отвечает отдельный механизм — rate limiting.
Механизмы закрывают разные отказы и друг друга не заменяют:
- Admission control отвечает на вопрос «сколько такого мой процесс переживет одновременно». Вопрос про ресурс, локальный для инстанса, срабатывает только под нагрузкой. Упорному атакующему он спокойно даст перебирать один аккаунт тысячами попыток — медленно, терпеливо, бесконечно, лишь бы тот не превышал лимит конкурентности.
- Rate limiting отвечает на вопрос «сколько такого вообще позволено одному клиенту». Это вопрос политики, и работать он обязан до того, как запрос дойдет до хеша.
Измерений нужно несколько: атакующий пойдет ровно в то, которое осталось открытым.
- По аккаунту. Ограничивает число попыток на один логин, классика против перебора одной цели. Поставите слишком низко — получите готовое оружие блокировки: зная только email жертвы, ее выбивают из аккаунта десятком неверных попыток.
- По IP. Ловит одну машину, которая долбится в тысячу аккаунтов. Против ботнета или списка, размазанного по residential-прокси, в одиночку почти бесполезен, но стоит дешево и лишним не бывает.
- По чему-то кроме IP. Фингерпринт устройства, client_id, ASN — что угодно, что переживает смену адреса. Тулинг для credential stuffing крутит адреса именно ради обхода лимита по IP, поэтому лимитеру нужна вторая зацепка.
- Глобально. Предохранитель на эндпоинт целиком. Если общий поток логинов за пять минут вырос втрое, что-то не так независимо от того, чей это аккаунт и чей адрес, и реагировать стоит еще до срабатывания порогов по отдельным субъектам.
Отдельно стоит следить за порядком. Лимитер, который смотрит счетчик после вызова Argon2id, бесполезен: память и процессор к этому моменту уже потрачены, отказ выдается постфактум. Сначала отклонить, потом хешировать, не наоборот.
Взаимозаменяемости между семафором и лимитером нет. Rate limiting не пускает враждебный трафик к хешированию в заметных объемах, а admission control следит, чтобы прошедшее (включая легитимные запросы, которые блокировать нельзя) не съело процесс целиком.
Амплификация: посчитайте свои множители
Если считать хеширование лимитированным ресурсом, следующий вопрос напрашивается сам: а не запускает ли один запрос больше одного хеша?
Тут стоит присмотреться к истории паролей. Чтобы запретить повторное использование недавнего пароля, введенное значение сверяют с сохраненными старыми хешами. Соль у каждой записи своя, поэтому свернуть проверку в один проход нельзя — по хешу на запись, последовательно.
Дальше арифметика. Пользователь ввел неверный пароль. Считаем хеш против текущей записи, не совпало. Чтобы выдать полезное «похоже, это ваш старый пароль», идем по истории: хеш, хеш, хеш.
История на пять записей — и одна неверная попытка стоит шести вычислений Argon2id. Множитель ×6 приходится на самый частый тип запроса, который вообще прилетает на логин: неверные пароли — это ровно то, что credential stuffing и генерирует. Ценность запрета на пятый с конца пароль сомнительная, а множитель вполне реальный.
Принцип общий: пройтись по путям аутентификации и найти все, что превращает один запрос в N хешей. История — типичный виновник, но так же устроена любая проверка «сверить со списком сохраненных секретов». По умолчанию список стоит держать коротким, а деплойментам с реальным требованием комплаенса дать возможность его увеличить — осознанно и с запасом по памяти.
Атаки, которые случаются на самом деле
Больше всего внимания в текстах про пароли достается офлайн-перебору. Аккаунты в этом году угоняют не им. Реалистичный список, примерно по частоте успеха:
Credential stuffing. Пары логин-пароль из чужих утечек проливают на ваш сервис. Ломать ничего не надо, ваше хеширование тут ни при чем: пароль уже есть в открытом виде, а пользователь его переиспользовал. Работают только три вещи — проверка по корпусам утечек при регистрации, rate limiting и второй фактор.
Фишинг и adversary-in-the-middle. Прокси-страница ретранслирует логин в реальном времени и забирает и пароль, и одноразовый код. TOTP и SMS обходятся полностью: пользователь аутентифицируется по-настоящему, просто в прокси атакующего, а тот все пересылает дальше. Инструменты для такого лежат готовыми.
Password spraying. Один популярный пароль на тысячи аккаунтов. Спроектировано ровно так, чтобы не упираться в лимит по аккаунту, — поэтому одного лимита по аккаунту и не хватает.
Атака на восстановление доступа. Зачем ломиться в дверь, если рядом есть «забыли пароль». Recovery часто оказывается самым слабым аутентификатором в системе, и знают об этом все.
Перечисление пользователей. Тоньше остальных, и цепляется за все вышесказанное. Если на несуществующего пользователя ответ приходит быстрее, чем на реального, разница в таймингах превращается в оракул: атакующий выясняет, кто у вас зарегистрирован. Здесь настоящий конфликт требований — пропускать хеш для неизвестных логинов хорошо против DoS и плохо для приватности. Решать надо осознанно, одинаковой формой ответа и одинаковой латентностью снаружи, а не как получится.
Из списка видно главное: тюнинг Argon2id не помогает почти ни от чего в нем. Хранение паролей — гигиенический минимум, а не безопасность аутентификации.
Что говорят актуальные рекомендации
NIST SP 800-63B-4 вышел в финальной редакции в июле 2025 и закрепил рекомендации, которые перечеркивают поколение корпоративных парольных политик. Коротко:
- Никаких требований к составу. Обязательные «заглавная плюс цифра плюс спецсимвол» запрещены (SHALL NOT). Они гонят пользователя к предсказуемым шаблонам вроде
Password1!, а энтропии почти не добавляют. - Никакой плановой ротации. Смена пароля раз в 90 дней отменяется, менять — только по признакам компрометации. Ротация надежно рождает
Summer2026, следомSummer2027. - Проверка по спискам. Верификатор обязан (SHALL) сверять новый пароль со списками скомпрометированных и слишком популярных. Из всего, что можно добавить, пользы отсюда больше всего: Have I Been Pwned отдает такую проверку через k-anonymity API, куда не уходит ни пароль, ни его полный хеш.
- Длина вместо сложности. Если пароль — единственный фактор, минимум обязан быть 15 символов; восьмисимвольный порог допустим только рядом со вторым фактором. Принимать нужно минимум 64 символа вместе с пробелами и Unicode. Не обрезать. Не блокировать вставку из буфера — этим ломаются менеджеры паролей, а они реально улучшают безопасность пользователей.
Если политика до сих пор требует спецсимволы и ротацию раз в квартал, но не сверяется с корпусами утечек, приоритеты расставлены задом наперед.
Мир уходит от паролей. Но не совсем
Passkey — не улучшенный пароль, а конструкция другого класса. Разницу стоит проговорить точно.
Passkey — это пара ключей, приватный и публичный. Приватный не покидает устройство. Аутентификация — подпись challenge-response, и браузер криптографически привязывает ее к origin, с которого пришел запрос.
Следствия:
- Красть нечего. У вас лежит публичный ключ. Полный дамп базы не дает ничего, что имело бы смысл перебирать, и все написанное выше про офлайн-атаки перестает быть актуальным.
- Переиспользовать нечего. Ключ уникален для каждого сайта по построению, и credential stuffing остается без входных данных.
- Фишить нечего. Вот это главное. Привязку к origin проверяет браузер, а не внимательность пользователя. Прокси на
versola-login.example.comне получит подпись, валидную дляversola.com. Атака, которая обходит TOTP, здесь просто не работает.
Последнее свойство и объясняет, почему индустрия пароли не улучшает, а заменяет.
Только никуда они не денутся.
Переживут пароли большинство прогнозов о своей смерти, и причины скорее структурные, чем технические.
Восстановление доступа задает нижнюю планку, и сделана она из другого материала. Пользователь потерял телефон. Что дальше? Каким бы ни был этот путь — письмо на почту, SMS, звонок в поддержку, — он и есть настоящий уровень защиты аккаунта. Можно раскатить идеальные passkeys и остаться ровно настолько защищенным, насколько защищена почта пользователя. Сложность passwordless не в самом passkey, а в восстановлении, которое не должно стать ни черным ходом, ни вечной блокировкой.
Первый вход чем-то закрывать все равно надо. До регистрации первого passkey пользователя нужно чем-то аутентифицировать.
Покрытие неровное. Общие рабочие станции, киоски, корпоративные машины с урезанными браузерами, старые устройства, региональные пробелы у платформ и длинный хвост enterprise-систем, которые не увидят WebAuthn в сроки, на которые вы влияете.
Синхронизация добавляет зависимость. Passkeys синхронизируются между устройствами, иначе ими невозможно пользоваться, — а значит, безопасность ключа частично упирается в аккаунт платформы и в его собственное восстановление. Улучшение реальное, но не абсолютное.
Реалистичный сценарий — не удаление, а понижение в статусе. Пароль перестает быть основным способом входа и становится запасным.
Отсюда последнее наблюдение, и оно неприятное. Запасным путям достается меньше проектирования, тестирования и мониторинга, чем основным, а атакующие идут туда, где внимания меньше. Чем шире расходятся passkeys, тем реже используется парольный путь и тем привлекательнее он как цель. Забросить его проще ровно тогда, когда забрасывать опаснее всего.
Ради этого все и написано: парольную часть стоит сделать правильно даже тогда, когда работаешь над тем, чтобы от нее избавиться.
Что это не решает
Цифры про admission control выше — на один процесс. Семафор ограничивает конкурентность на одном инстансе и ничего не знает про тот же аккаунт, который долбят одновременно через десять инстансов за балансировщиком — каждый со своим бюджетом хеширования, в блаженном неведении об остальных девяти. Лимит на весь кластер нужен через общее хранилище — Redis или счетчик в базе, — и это хранилище само становится проблемой пропускной способности, как только логин-трафика достаточно, чтобы такой лимит вообще понадобился.
Цифры 200 запросов в секунду и 285 МиБ — иллюстрация, а не готовый расчет под ваш случай. Реальные значения зависят от параметров Argon2id, лимита памяти контейнера и от того, сколько этой памяти уже занято всем остальным в процессе. Прежде чем выбирать размер семафора, измерьте свой инстанс — не берите эти цифры как есть.
Проверка новых паролей по корпусам утечек, которую эта статья ставит выше правил про спецсимволы, — внешняя зависимость: вызов k-anonymity API либо корпус, который поддерживаете сами. Она добавляет задержку на каждую регистрацию и смену пароля, и ни Argon2id, ни семафор этого не решают сами по себе.
Ничего экзотического тут нет. Это домашняя работа, без которой цифры выше ничего не значат для конкретно вашего деплоя.
Коротко
Если пароли у вас хранятся, примерно в таком порядке приоритета:
- Argon2id, минимум
m=19456, t=2, p=1. Не MD5, не SHA-256 и не «SHA-256, но с солью». - Своя случайная соль на каждую запись, 16 байт из CSPRNG.
- Перец вне базы. Дешевая страховка от утечки одного только дампа.
- Лимит на конкурентность хеширования. Семафор по размеру доступной памяти. Шаг, который пропускают почти все, и он же разница между «медленно» и «лежит».
- Проверка на амплификацию. Сколько хешей запускает один запрос? Обычно виновата история паролей.
- Сверка новых паролей с корпусами утечек. Пользы больше, чем от любого правила про спецсимволы.
- Отказ от требований к составу и плановой ротации. Так рекомендует NIST, и пользователям так лучше.
- Rate limiting в нескольких измерениях. По аккаунту, по IP, глобально.
- Восстановление доступа как полноценный аутентификатор — атакующие давно считают именно так.
- Passkeys в проде, а запасной путь проектировать так, будто его будут атаковать. Будут.
Идеальная парольная система никому не нужна. Нужна такая, где пароль значит все меньше с каждым релизом, и в день, когда его наконец выключат, ничего ценного не потеряется.