Updated: попытка снова неудачна.

Всегда хотелось, чтобы хакер не мог взломать перебором чужой пароль на сайте.
Например, если пользователь похвастается хакеру, что его пароль состоит только из цифр, то скоро пользователь потеряет свой аккаунт.

А как быть, если пользователь сообщил по телефону свой пароль жене, а хакер его услышал?

Что?! Хакер знает пароль? Все, это фиаско. Можно ли помочь такому пользователю усложнить угон его аккаунта? Меня всегда волновал этот вопрос и, кажется, я нашел способ как это сделать. Или переоткрыл его, как это часто бывает. Ведь все уже давно придумано до нас.

Вводная


  • Пользователь хочет на сайте иметь пароль «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` не пустое, значит, что пользователь придумал секретное слово, и можно применять новый метод аутентификации. В противном случае использовать старый аутентификатор.