Как и было обещано в соседней теме, где рассказывался велосипед, выкладываю описание алгоритма SRP RFC2945 — способе регистрации и аутентификации пользователей безопасным образом по небезопасному каналу. Вот только в процессе подготовки статьи я обнаружил более свежую версию протокола, SRP-6, вместе с реализацией, в связи с чем решил выбросить свои архаичные наработки по SRP-3, и просто дать ссылки на имплементацию новой версии.

Secure Remote Password (SRP) протокол описывает установление безопасного соединения, используя для аутентификации пару логина и пароля, при которой пароль используется только на этапе регистрации и аутентификации и только на стороне клиента. Благодаря использованию схемы близкой к Диффи-Хеллману, SRP обеспечивает безопасную аутентификацию пользователя по паролю без необходимости передачи этого пароля, и даже имея полную запись обмена по каналу мы не получаем информации достаточной, для аутентификации данным пользователем. Так же, если сервер будет скомпрометирован и утечёт «в народ» вся база логинов со стороны сервера, злоумышленник не сможет аутентифицироваться ни одним из пользователей.x

Итак, общая схема аутентификации пользователя используя сокращенный по числу обменов SRP выглядит так:
  • Выбирается для работы «поле безопасности»:
    H = односторонняя функция хеширования, достаточно безопасная на текущий момент.
    N = "безопасное простое
    " = 2*q+1 простое, где q тоже простое число.
    g = генератор по модулю N => для любого 0 < X < N существует и единственный x такой, что g^x % N = X.
    k = параметр-множитель. В SRP-6a это H(N, g); в SRP-6 это 3
  • При регистрации, клиент вычисляет следующее:
    s = random_string()
    x = H(s, p)
    v = g^x % N

    И присылает серверу три поля:
    I = username
    s = salt
    v = password verifier

    При правильном выборе g и N, передача этих данных достаточно безопасна — из них вычислительно трудно получить p.
    Безопасность обеспечивается сложностью операции дискретного логарифмирования — получения x из v=g^x%N зная v, g, N.
    Именно за счет этого, даже если с сервера сольют базу пользователей (привет, Sony!), получить пароли, или представиться пользователем будет невозможно.
    Впрочем, следует учесть, что если база пользователей слита, то протокол перестаёт быть защищенным от представления злоумышленника _сервером_. То есть данный метод никак не гарантирует клиенту, что сервер тот, за кого себя выдаёт. Однако Man-In-The-Middle атака остаётся невозможной — злоумышленник не может (вычислительно сложно) получить у себя общий с сервером ключ сессии K.
  • Пользователь:
    I = username
    a = random()
    A = g^a % N

    На сервер передаётся пара из логина I и вычисленного A.
    Сервер должен проверить и убедиться, что A != 0.
  • Сервер достаёт из своих загашников два числа, соответствующих логину:
    s = соль, созданная при регистрации
    v = верификатор пароля, вычисленный при регистрации
    После чего генерирует случайно�� число b, и вычисляет
    B=(k*v + g^b % N) % N
    Клиенту присылается пара из соли s и вычисленного B.
    Клиент в этот момент должен проверить, что B != 0.
  • Обе стороны вычисляют скремблер:
    u = H(A, B).
    Если u == 0, соединение прерывается.
  • Клиент вводит свой пароль p, на основе которого вычисляется общий ключ сессии:
    x = H(s, p)
    S = ((B - k*(g^x % N)) ^ (a + u*x)) % N
    K = H(S)
  • Сервер со своей стороны так же вычисляет общий ключ сессии:
    S = ((A*(v^u % N)) ^ b) % N
    K = H(S)
  • Начиная с этого момента, если клиент и сервер оба _действительно_ знают друг друга, есть идентичное число K у обоих.
    Чтобы доказать друг дружке, что они сами себе злобные буратины, происходит второй раунд обмена.
    Клиент генерирует подтверждение:
    M = H( H(N) XOR H(g), H(I), s, A, B, K)
    и отсылает серверу.
    Сервер у себя вычисляет M используя свою копию K, и проверяет равенство. Если они равны, значит у клиента точно такой же K, и, следовательно, клиент тот, за кого себя выдаёт. Если не равны — соединение прерывается, БЕЗ отсылки клиенту R.
    Если же они равны, то чтобы доказать теперь клиенту, что он не верблюд, он отсылает ему в ответ свою подтверждалку:
    R = H( A, M, K )
    Клиент аналогично вычисляет у себя R используя вычисленную у себя K, и сравнивет с полученной от сервера.

В результате, процесс аутентификации требует 2 обращения к серверу и наличие какого-либо программного клиента на стороне пользователя. Еще раз обращаю внимание, на в первую очередь защиту со стороны сервера. Сервер отдаёт как можно меньше информации, произ��одя в первую очередь проверки у себя. Поэтому злоумышленник, имеющий у себя s и v может представиться сервером, но не сможет быть посредником.

Для реализации входа, можно использовать классическую форму логин+пароль, на onSubmit которой повешен аутентификатор.
В случае отсутствия на стороне клиента яваскрипта, форма отправится «как есть», что передаст по небезопасному каналу пароль, но позволит аутентифицироваться даже с «тупых» клиентов (путем симуляции всех действий «пользователя» на сервере).
В случае присутствия JS, аутентификация реализуется путем отправки двух спец-форм используя AJAX методики, производя вычисления между ними.

Впрочем, если сайт не требуется чтобы работал без JS (например, fallback вообще не возможен, слишком сложен, «не тянем» по времен�� и силам разработчиков), то мне кажется более разумным реализация такой схемы: поля вообще не находятся в форме, после выхода из поля логина поле пароля блокируется на некоторое время, и рисуется рядом ajax спиннер. После получения с сервера соли для юзернейма, поле пароля разблокируется и можно ввести туда пароль.
Смысл в разделении необходим для того, чтобы дать JS время на вычисления. Всё-таки там довольно много математики с большими числами. Когда я исследовал этот вопрос на SRP-3 используя SHA1 (лет пять эдак назад), использовать 512битные ключи оказалось слишком медленным — до 30 секунд на вычисления для подтверждения пароля. При этом весь UI браузера [особенно IE6 ;)] блокируется на всё время вычисления. Решением может быть вычисления используя континуации (запуск вычисления кусочками через setTimeout(), сохраняя контекст между вызовами) или вынося вычисления во flash / java. Причем java предпочтительнее с точки зрения скорости вычисления, а flash — с точки зрения вероятности доступности у пользователя.

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

Еще очень важно понимать, что алгоритм н��как не защищает от фишинга. Поддельный сайт вполне может выдать идентично выглядящую страницу, на которой будут так же расположенные поля логина и пароля, вплоть до симуляции вычислений вычисляя, например, сумму ряда чисел от 1 до 1e12 через однострочник, на самом деле передавая на сервер злоумышленника пароль «как есть». Именно поэтому мне кажется использование подписанного Java аутентификатора более привлекательным — при подделке надо будет симулировать еще и подпись на ява-апплете авторизаторе… Что, в общем-то, используя человеческую лень и невнимательность по-прежнему возможно.

Готовые реализации SRP на JS можно найти по ссылкам:

Почитать еще по теме можно и нужно: