Например, если пользователь похвастается хакеру, что его пароль состоит только из цифр, то скоро пользователь потеряет свой аккаунт.
А как быть, если пользователь сообщил по телефону свой пароль жене, а хакер его услышал?
Что?! Хакер знает пароль? Все, это фиаско. Можно ли помочь такому пользователю усложнить угон его аккаунта? Меня всегда волновал этот вопрос и, кажется, я нашел способ как это сделать. Или переоткрыл его, как это часто бывает. Ведь все уже давно придумано до нас.
Вводная.
- Пользователь хочет на сайте иметь пароль 12345.
- Хакер может легко подобрать этот пароль.
- Но пользователь должен войти, а хакер нет. Даже если логин и пароль хакеру известны.
- и никаких SMS с секретными кодами и посредниками в виде дополнительных сервисов. Только пользователь и ваш сайт со страницей логина.
- а еще можно будет сравнительно безопасно в троллейбусе сказать своей жене: Галя, я на сайте site для нашего логина alice поменял пароль на 123456 говорят, он более популярный, чем наш 12345. И не бояться, что аккаунт взломают за секунду.
Как работает метод? Вся конкретика под катом.
Что потребуется?
- концепт объясняет только метод аутентификации
- реализация требует хранить только "имя пользователя", "пароль", "соль1" и "соль2". Да, две соли.
- обойдемся без таблиц логирования и счетчиков в redis
- не будем вести таблицы с IP-адресами
- не будем использовать SMS
- не будем блокировать попытки входа в систему. Как известно из моей прошлой неуспешной попытки, бесполезно блокировать вход даже если хакер упрется в ограничение по времени, он просто начнет подбирать пароли сразу у нескольких пользователей. Кроме того, от ограничений пострадает и сам пользователь. Не звонить же ему в поддержку, чтобы авторизоваться на вашем сайте с прикольными картинками?
- пользователь может поменять пароль в любое время и сделать его недействительным на остальных устройствах. Это обычное правило, но мне кажется его стоит упомянуть.
- можно сделать процесс подбора пароля по словарю более тяжелым для хакера (опционально, будет упомянуто ниже).
Суть метода.
Позволить пользователю иметь пароль 12345, а взлом этого пароля должен быть усложнен. Например, как подбирать пароль, который выглядит, как хэш.
Как?
Представьте, если бы в браузере всегда была уникальная соль, которой можно было солить пароль. Каждому пользователю по соли. Зачем она нужна? Чтобы шифровать. Например, если зашифровать строку 12345 с солью saltsalt в argon2id, то получится "$argon2id$v=19$m=16,t=2,p=1$c2FsdHNhbHQ$jX94laSi6vo9AhS+bHwbkg". Поменяй соль и хэш будет другим. Один алгоритм зашифрует одинаковые пароли по-разному, если использовать разную соль для каждого. Годится.
Но где взять эту соль изначально? Да вот же она сидит перед монитором. Пусть выдавит из себя два-три лишних символа и авторизуется уже наконец по-человечески. Рядом кот бегает? Ну, пусть будет cat. Что такое cat? Это наше секретное слово. Мы его сообщим на сервер при регистрации, а он сгенерирует по этому слову соль. А потом эту соль пришлет нам. Все соль в браузере есть. Теперь пароль. А пароль тоже шифруем и солим той солью, что прислал сервер.
Теперь мы не шлем 12345. Мы шлем хэш, и так-как у каждого пользователя своя соль, хэш получается разный.
Кажется, брутфорсу сейчас поплохеет: мало того, что придется делать дополнительные вычисления и перебирать длинные строки хэшей аргона вместо простых цифр, так еще и у каждого пользователя будет свой хэш теперь бесполезно пробовать одну и ту же строку в виде пароля для проверки ее у всех пользователей. Допустим, три пользователя выбрали один и тот же пароль: 12345. Но хэш у них получится разный. Потому что у каждого разная соль.
- Браузер должен вычислять хэш пароля используя соль, которую ему ранее прислал сервер. Отправлять он должен хэш, а не сам пароль.
- Сервер присылает соль по секретному слову, которое известно только пользователю. Оно может быть простым. Например cat.
- У каждого пользователя должна быть своя соль.
- Два пользователя, выбравшие одинаковое секретное слово, должны иметь разную соль.
- Сервер не должен сообщать было ли использовано правильное секретное слово и верна ли соль для этого пользователя иначе это будет подбор двух простых паролей вместо одного.
- Если пользователь меняет секретное слово, меняется и соль.
То есть, для защиты своего простого пароля, пользователь должен придумать еще одно очень простое слово. Он вводит это слово везде, где хочет пройти аутентификацию, а потом потребуется вводить только пароль. Пока он не почистит куки.
- зашел на сайт
- ввел логин и секретное слово
- ввел пароль
- готово
Пароль и секретное слово могут быть очень простыми. Один или два символа. Например, пароль 12345 и секретное слово 42. И если кто-то еще придумает секретное слово 42, то это будет не страшно.
Как это работает. Пошаговый концепт.
У нас есть следующие элементы:
- веб-сервер
- база данных и таблица users:
- login
- password_hash
- salt_unique_for_each_user
- salt_for_password
- браузер пользователя
- браузер хакера
- страницы логина и регистрации на сайте
- скрипт, который перехватывает событие submit для формы логина
Далее нам понадобятся два разных алгоритма, которые могут быть реализованы даже на одной шифровальной системе просто с разными параметрами:
- ALG1 асимметричный алгоритм шифрования, который генерирует хэш из строки и соли. ALG1(str, salt) = hash1. Этот алгоритм используется только на сервере.
- ALG2 асимметричный алгоритм шифрования, который генерирует хэш из строки и соли. ALG2(str, salt) = hash2. Этот алгоритм используется публично и должна быть возможность его реализации на клиенте (в нашем примере на javascript).
Кроме того нам понадобится еще два алгоритма попроще:
- ALG_SALT алгоритм, который вычисляет случайную соль в виде строки символов. ALG_SALT() = salt. Этот алгоритм используется только на сервере.
- ALG_PASS алгоритм, который генерирует случайный простой пароль. ALG_PASS() = pass. Этот алгоритм используется только на сервере.
События пошагово:
- Пользователь переходит на страницу регистрации, так-как у него пока нет логина.
- Сервер показывает форму с двумя полями: логин + простое секретное слово.
- Пользователь выбирает логин alice
- Пользователь выбирает секретное слово cat
- Пользователь нажимает кнопку Отправить.
Cервер проверяет и удостоверяется, что пользователь alice отсутствует в БД.
Сервер вычисляет следующие значения:
$salt_unique_for_each_user = ALG_SALT(); // строка "saltsalt"
$salt_for_password = ALG1("cat", $salt_unique_for_each_user); // строка "$argon2id$v=19$m=16,t=2,p=1$c2FsdHNhbHQ$jX94laSi6vo9AhS+bHwbkg"
$user_simple_password = ALG_PASS(); // строка "12345"
$user_simple_password_hashed = ALG2($user_simple_password , $salt_for_password); // строка "$argon2id$v=19$m=16,t=2,p=1$JGFyZ29uMmlkJHY9MTkkbT0xNix0PTIscD0xJGMyRnNkSE5oYkhRJGpYOTRsYVNpNnZvOUFoUytiSHdia2c$b+6ROJVsZ62UXA7hEAg0AQ"
Сервер создает в таблице пользователей запись и сохраняет данные:
INSERT INTO `users` (login, password_hashed, salt_unique_for_each_user, salt_for_password) VALUES ("alice", "$argon2id$v=19$m=16,t=2,p=1$JGFyZ29uMmlkJHY9MTkkbT0xNix0PTIscD0xJGMyRnNkSE5oYkhRJGpYOTRsYVNpNnZvOUFoUytiSHdia2c$b+6ROJVsZ62UXA7hEAg0AQ", "saltsalt", "$argon2id$v=19$m=16,t=2,p=1$c2FsdHNhbHQ$jX94laSi6vo9AhS+bHwbkg").
Сервер показывает пользователю страницу успеха регистрации с сообщением: Пользователь alice успешно создан. Используйте временный пароль 12345 для входа.
Пользователь радостно кричит: Ура, я зарегистрировался на сайте site под ником alice и мне дали пароль 12345. Какой смешной и простой пароль!. Но у квартиры пользователя очень плохая звукоизоляция, и его хакер-сосед все услышал.
- Хакер вбивает адрес сайта в своем браузере.
- Браузер хакера отсылает пустые куки.
- Сервер проверяет запрос хакера есть ли кука salt. Не находит ее.
- Прежде, чем хакер пришлет украденные логин и пароль, браузер должен знать соль, чтобы ей зашифровать пароль.
- Браузер хакера пока не хранит соль в куке salt.
- Сервер присылает форму логина с двумя полями: логин + секретное слово, чтобы дать пользователю возможность получить соль.
Хакер озадачен. Пока оставим его.
- Пользователь возвращается на страницу логина.
- Браузер пользователя отсылает пустые куки.
- Сервер проверяет запрос пользователя есть ли кука salt. Не находит ее.
- Прежде, чем пользователь пришлет логин и пароль, браузер должен знать соль, чтобы ей зашифровать пароль.
- Браузер пользователя пока не хранит соль в куке salt.
- Сервер присылает форму логина с двумя полями: логин + секретное слово, чтобы дать пользователю возможность получить соль.
- Пользователь вводит login alice, secret cat и нажимает кнопку "Отправить".
Сервер получает запрос и видит, что вместо пароля прислали секретное слово.
- Сервер выбирает запись из базы данных с логином alice и берет значения `salt_unique_for_each_user` -> $db_salt_unique_for_each_user и `salt_for_password -> $db_salt_for_password`.
- Сервер делает вычисления схожие с теми, что он делал при регистрации. Вычисляет значение: $salt_for_password = ALG1(cat, $db_salt_unique_for_each_user).
- Сервер отсылает значение соли $salt_for_password в ответе пользователю. Эта соль правильная. Если с ее помощью зашифровать пароль 12345, получится хэш, который сейчас хранится в БД. В заголовках ответа от сервера указано `установить куку salt = $db_salt_for_password`. Также давайте сохраним и логин: `установить куку login = alice`.
Пояснение: Сервер никак не уведомляет какая соль была отправлена правильная или нет. Результат ее использования будет ясен, когда с ней попытаются авторизоваться с правильными логином и паролем.
- Пользователь получает ответ сервера. Его страница либо перегружается, либо сразу динамически меняется.
- Браузер пользователя отсылает куки: login = alice, salt = "$argon2id$v=19$m=16,t=2,p=1$c2FsdHNhbHQ$jX94laSi6vo9AhS+bHwbkg".
- Сервер проверяет запрос пользователя есть ли кука salt. Находит ее.
- Браузер уже имеет соль, чтобы ей зашифровать пароль.
- Сервер присылает форму логина с двумя полями: логин (уже имеет значение alice) + пароль.
- Пользователь вводит свой простой пароль 12345 и нажимает кнопку "Отправить".
- Браузер перехватывает событие onSubmit.
- Вычисляет $password_hashed = ALG2(12345, "$argon2id$v=19$m=16,t=2,p=1$c2FsdHNhbHQ$jX94laSi6vo9AhS+bHwbkg").
- Отправляет данные alice/$argon2id$v=19$m=16,t=2,p=1$JGFyZ29uMmlkJHY9MTkkbT0xNix0PTIscD0xJGMyRnNkSE5oYkhRJGpYOTRsYVNpNnZvOUFoUytiSHdia2c$b+6ROJVsZ62UXA7hEAg0AQ, а сам пароль 12345 никуда не шлет.
Сервер получает запрос на аутентификацию:
- Данные логин+пароль: alice/$password_hashed
- Идет в БД, достает значение `password_hashed` -> $db_password_hashed.
- Сравнивает $db_password_hashed === $password_hashed?
- Хэши совпадают, авторизация успешна.
Примечание: В моем примере сервер сличает хэши напрямую. Но хранить в БД строки, которые по факту уже являются паролями, нельзя. Их можно украсть и потом использовать в форме логина-пароля. Поэтому нужно хэшировать хэши как бы странно это не звучало. Это значит, что понадобится третья соль. Но хранить ее надо не в БД, а в переменной окружения. Впрочем, это уже детали реализации, которые я пропустил для упрощения.
Тем временем наш хакер решает проверить эту странную форму входа:
- Хакер вводит login alice, secret dog и нажимает кнопку "Отправить".
- Сервер получает запрос хакера и видит, что вместо пароля прислали секретное слово.
- Сервер выбирает запись из базы данных с логином alice и берет значения `salt_unique_for_each_user` -> $db_salt_unique_for_each_user и `salt_for_password` -> $salt_for_password.
- Сервер вычисляет значение соли и выдает ее, но она неправильная, потому что кодовое слово чужое: $result_fake_salt = ALG1(dog, $db_salt_unique_for_each_user). Впрочем, сервер об этом тактично умалчивает.
Сервер отсылает вычисленное значение соли обратно в браузер пользователя. В заголовках указано `установить куку salt = $result_fake_salt`. Также сохраняется и логин: `установить куку login = alice`.
Пояснение: Чтобы помочь хакеру в деле нелегкого труда, сервер отправляет ему соль. Но определить со стороны: правильное ли было секретное слово или нет невозможно.
- Хакер получает ответ сервера. Его страница либо перегружается, либо сразу динамически меняется.
- Браузер хакера отсылает куки: login = alice, salt = $result_fake_salt.
- Сервер проверяет запрос пользователя есть ли кука salt. Находит ее.
- Браузер хакера уже имеет соль, чтобы ей зашифровать пароль.
- Сервер присылает форму логина с двумя полями: логин (уже имеет значение alice) + пароль.
- Хакер вводит украденный простой пароль 12345 и нажимает кнопку "Отправить".
- Браузер перехватывает событие onSubmit.
- Вычисляет $password_hashed = ALG2(12345, $result_fake_salt).
- Отправляет данные alice/$password_hashed.
Сервер получает запрос на аутентификацию alice/$password_hashed.
Идет в БД, достает значение `password_hashed` -> $db_password_hashed.
Сравнивает: $password_hashed === $db_password_hashed? Nope.
Хэши этих изначально одинаковых паролей не совпадают. Потому что их солили по-разному.
Хакер не сдается и идет регистрировать другого пользователя на сайте.
Совершенно случайно он вводит то же самое секретное слово, что и пользователь за стеной cat.
Хакер получает валидную соль для пароля к новому аккаунту, и пробует ее подставить в скрипт для хэширования.
К счастью, генерация соли для паролей использовала вторую соль (`salt_unique_for_each_user`), которая для каждого пользователя генерируется по-новому. Так что разные пользователи даже с одинаковыми паролями и что самое главное секретными словами, будут иметь разные соли. И соль пользователя с тем же секретным словом, не совпадет с солью другого. И совпадение паролей тоже не будет являться проблемой.
Теперь, что касается усложнения перебора паролей по словарю. Если мы модифицируем ALG2, который является общим и для сервера и для клиента, и сделаем его трудозатратным, это серьезно осложнит перебор для хакера. Напомню, ALG2 это процесс получения хэша пароля, который отправляется на сервер. На сервере этот хэш уже вычислен и хранится в БД:
- сервер будет выполнять операцию ALG2 только один раз при записи пароля в БД или смене пароля на новый
- клиент будет выполнять операцию ALG2 только во время аутентификации (которую нужно не путать с авторизацией). Допустим, клиент ошибся пару раз при вводе пароля это не страшно.
- Хакер будет делать это постоянно для каждого пароля, с чем его и можно будет поздравить. Особенно цинично, что будут затрачиваться титанические усилия на пароли типа 123/1234/12345.
На слабых машинах операция может выполняться значительно дольше, чем на быстрых. Это может стать проблемой. Так что можно не делать усложнение алгоритма.
Завершу описание концепта бочкой дегтя:
- Если пользователь случайно неправильно введет секретное слово, он попадет в ситуацию, когда он не сможет войти по своему паролю. Придется сбросить секретное слово (в нашем случае удалить куки) и послать запрос заново. Это можно реализовать прозрачно по нажатию одной кнопки, но до этого пользователь должен еще догадаться. Можно сбрасывать принудительно при 5 неправильных попытках входа.
- Два пользователя на одном компьютере вынуждены будут постоянно сбрасывать соль друг друга.
- Два разных компьютера будут получать одну и ту же соль для пароля
- Если соль сменится на сервере через один компьютер, другой компьютер со старой солью не будет знать, что ее нужно поменять
- Можно украсть соль с компьютера и с ее помощью осуществить очень быструю атаку на аккаунт, зная что пароль очень простой.
и ложкой меда:
- пользователь может иметь несколько секретных слов для выполнения различных задач. Например, "cat" это зайти на почту, а "termorectal" это показать фейковую страницу с ничем не примечательными письмами. Конечно, два пароля можно организовать в любой системе. Но второй пароль должен быть таким же сложным, как и первый. Здесь же можно помнить два простых пароля любого вида, используя удобочитаемые слова.
- Возможна интеграция секретного слова в уже существующие системы аутентификации. Если у пользователя значение в БД `salt_for_password` не пустое, значит, что пользователь придумал секретное слово, и можно применять новый метод аутентификации. В противном случае использовать старый аутентификатор.