Сообщений 0    Оценка 600 [+1/-0]         Оценить  
Система Orphus

Класс для работы с паролями в среде .NET

Как сохранить пароль так, чтобы его никто не узнал

Автор: Alex Fedotov
The RSDN Group

Источник: RSDN Magazine #1-2005
Опубликовано: 22.05.2005
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Немного о хранении паролей
Использование класса Password
Начальная установка и смена пароля
Проверка пароля
Забыл пароль?
Реализация
Производительность
Заключение
Литература

Исходный код и демонстрационное приложение

Введение

Современные Web-приложения, как правило, аутентифицируют (то есть проверяют подлинность) своих пользователей путем проверки имени пользователя и пароля. Для реализации такой проверки приложение должно в том или ином виде сохранить пароль пользователя или производную от него, чтобы иметь образец для сравнения. При этом, безусловно, пароль должен быть сохранен так, что даже если сервер, на котором работает приложение, взломан, злоумышленники не могли завладеть паролями пользователей. В этой статье вашему вниманию предлагается небольшой класс Password, который облегчает безопасное хранение паролей, выполняет их проверку, а также может использоваться для генерирования случайных паролей.

Немного о хранении паролей

Безопасное хранение паролей является хорошо исследованной задачей в науке о безопасности, которая гласит, что лучший способ сохранить пароль – это не хранить его вовсе, а сохранить хэш пароля – результат применения односторонней хэш-функции к исходному паролю. Односторонняя хэш-функция замечательна тем, что зная значение аргумента, вычислить ее значение очень легко, но вот по значению функции найти подходящий аргумент за разумное время практически невозможно.

Таким образом, если мы имеем пароль P, то вместо того, чтобы хранить значение P в явном виде, мы сохраняем значение H(P), где H – подходящая хэш-функция. Даже если злоумышленникам удастся завладеть сохраненными хэшами, то им теоретически понадобятся сотни лет, чтобы найти исходные пароли, которым соответствуют эти значения.

Когда приходит время проверить пароль P’, введенный пользователем, мы вычисляем H(P’) и сравниваем его с сохраненным значением H(P). Если значения хэш-функции совпадают, это с очень высокой степенью вероятности говорит о том, что и ее аргументы совпадают, то есть введенный пользователем пароль совпадает с тем паролем, который был задан в самом начале.

Описанная выше схема имеет, однако, существенный изъян. Дело в том, что пользователи довольно предсказуемы в выборе паролей, что делает возможной так называемую атаку по словарю. Суть атаки в том, что злоумышленникам достаточно перебрать относительно небольшой набор паролей, состоящий из широкоупотребительных слов, имен, названий городов, и т.д. Хакеры могут пойти еще дальше и заранее вычислить значения хэш-функции для всех слов из словаря. В таком случае, завладев нашей базой паролей, им останется только сравнить значения из нашей базы с заранее вычисленными значениями хэш-функции, чтобы найти все пароли из словаря.

Другой недостаток предложенного метода состоит в том, что если два пользователя выберут один и тот же пароль, то и значения в базе данных паролей будут совпадать. Таким образом, «сломав» пароль одного пользователя, злоумышленники автоматически узнают пароль другого.

Чтобы избежать этих недостатков и максимально затруднить задачу злоумышленникам, используется схема хэширования с синхропосылкой. В этой схеме сначала генерируется небольшое случайное число – синхропосылка (в англоязычной литературе это число называют словом salt [соль])– которая хэшируется вместе с исходным паролем. Синхропосылка не является секретной, но является уникальной для каждого сохраненного пароля и хранится вместе с полученным хэшем.

Когда нужно проверить пароль, синхропосылка и хэш извлекаются из базы данных, введенный пароль хэшируется вместе с синхропосылкой, и результат сравнивается со значением хэша, извлеченным из базы данных. Если они совпадают, это говорит о том, что введенный пароль совпадает с исходным паролем.

Теперь, даже если два пользователя выберут одинаковый пароль, сохраненный хэш будет разным, поскольку синхропосылка уникальна для каждого сохраненного пароля. Более того, теперь злоумышленники не могут заранее вычислить значения хэш-функции для словарной атаки. Им придется хэшировать каждое слово отдельно с каждой синхропосылкой, что значительно замедляет процесс подбора пароля (но, впрочем, не делает его невозможным, поэтому постарайтесь, чтобы ваш пароль не был словом из словаря).

Использование класса Password

Один из вариантов реализации схемы хэширования с синхропосылкой предлагается вашему вниманию в виде класса Password. Ниже приводится краткое описание свойств и методов этого класса, а затем приводится несколько типичных вариантов его использования.

Конструкторы

Конструктор с одним аргументом инициализирует объект указанным паролем в открытом виде, при этом автоматически генерируется синхропосылка и вычисляется хэш пароля.

        public Password(char[] clearText);

Конструктор с двумя аргументами инициализирует объект заданной синхропосылкой и хэшем, представленными в виде строк, закодированных в формате base64. Этот конструктор просто сохраняет предоставленные значения, ничего не вычисляя.

        public Password(string salt, string hash);

Использование base64 удобно, если синхропосылка и хэш хранятся в текстовом или в XML-файле. Для тех случаев, когда удобнее работать с двоичными данными, есть конструктор, принимающий непосредственно массивы байтов.

        public Password(byte[] salt, byte[] hash);

Свойства

Свойство Salt возвращает установленную или сгенерированную синхропосылку в виде строки в формате base64.

        public
        string Salt { get; }

Свойство Hash возвращает установленное или вычисленное значение хэша в виде строки в формате base64.

        public
        string Hash { get; }

Свойства RawSalt и RawHash возвращают синхропосылку и хэш в виде массива байтов.

        public
        byte[] RawSalt { get; }
publicbyte[] RawHash { get; }

Методы

Метод Verify предназначен для проверки пароля, введенного пользователем. Метод возвращает true, если указанный пароль соответствует синхропосылке и хэшу сохраненным в объекте, и false – в противном случае.

        public
        bool Verify(char[] clearText);

Наконец, статический метод Generate генерирует случайный пароль.

        public
        static
        char[] Generate();

Как вы наверно уже обратили внимание, все свойства и методы, возвращающие или принимающие пароль в открытом виде, используют тип данных char[], а не string для передачи пароля. Причина в том, что при использовании типа string приложение не имеет контроля над тем, когда занимаемая строкой память будет обнулена. В результате открытый текст пароля может долгое время оставаться в памяти и в файле подкачки, что увеличивает вероятность утечки. Если же передавать пароль в виде массива символов, то массив можно обнулить сразу после использования.

Заметим, что даже использование char[] полностью не решает проблему, так как сборщик мусора может перемещать массив в памяти без нашего ведома. Этого тоже можно избежать, но только с использованием unsafe-кода, и рассмотрение подобной техники выходит за рамки данной статьи. Разработчики .NET осознают эту проблему – в .NET Framework 2.0 появится класс SecureString, специально предназначенный для хранения разного рода секретов.

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

Начальная установка и смена пароля

Первое применение - это, конечно же, начальная установка пароля. Когда пользователь создает себе учетную запись в приложении (или же учетная запись создается администратором, что ничего не меняет), он вводит свой пароль открытым текстом. Наша задача – сгенерировать синхропосылку и хэш, и сохранить их в постоянном хранилище, например, в базе данных. Используя класс Password, сделать это можно следующим образом:

        // пароль должен быть каким-то образом получен от пользователя
        char[] clearText = …;

// инициализируем объект Password паролем в открытом виде
Password password = new Password(clearText);

// сохраняем сгенерированные значения синхропосылки и хэша
... = password.Salt
... = password.Hash

Такой же подход можно использовать и для смены пароля. Прежде чем давать пользователю установить новый пароль, конечно, нужно убедиться, что он знает старый, о чем – ниже.

Проверка пароля

Следующая задача – проверка пароля. Когда пользователь пытается войти в систему и вводит свое имя пользователя и пароль, нам нужно по имени пользователя извлечь из базы данных синхропосылку и хэш. Затем мы можем инициализировать объект Password этими значениями и использовать метод Verify, чтобы убедиться в правильности пароля:

        // пользователь вводит свое имя и пароль
        string userName = …;
char[] clearText[] = …;

// используя имя пользователя, извлекаем из базы данных синхропосылку и// хэш и инициализирует объект Password этими значениями
Password password = new Password(salt, hash);

// теперь можно воспользоваться методом Verify для проверки пароляif (password.Verify(clearText))
    // пароль верныйelse// пароль неверный

Забыл пароль?

А что делать в том случае, если пользователь забыл свой пароль? Очевидно, мы не можем сообщить ему пароль, так как не знаем его сами! Все, что мы знаем, это хэш пароля и синхропосылка, из которых, как известно, получить исходный пароль весьма затруднительно. Единственное, что мы можем сделать – это сгенерировать новый пароль и сообщить его пользователю. С помощью класса Password задача решается следующим образом:

        char[] clearText;
Password password;

try
{
    // генерируем случайный пароль
    password = new Password(clearText = Password.Generate());

    // отправляем пароль пользователю...
}
finally
{
    Array.Clear(clearText, 0, clearText.Length);
}

// соответствующие синхропосылку и хэш сохраняем в нашей базе данных
… = password.Salt;
… = password.Hash;

Эту статью сопровождает небольшое тестовое приложение, которое вы можете использовать для проверки всех трех типичных применений класса.


Если ввести пароль в поле "Пароль", и нажать кнопку "Сгенерировать", программа сгенерирует синхропосылку и хэш, соответствующие введенному паролю. Для проверки пароля, нужно ввести синхропосылку и хэш в соответствующие поля, пароль, подлежащий проверке – в нижнее поле "Проверка", и нажать кнопку "Проверить". Наконец, чтобы сгенерировать случайный пароль, нужно оставить поле "Пароль" пустым и нажать кнопку "Сгенерировать".

Реализация

Реализация класса Password довольно проста. В нем всего два поля данных, которые хранят синхопосылку и хэш пароля, соответственно.

      private
      byte[] _salt;       // синхропосылкаprivatebyte[] _hash;       // хэш пароля

Конструкторы

Конструктор с двумя строковыми аргументами просто преобразует синхропосылку и хэш из base64 в двоичное представление и сохраняет в соответствующих полях класса:

        public Password(string salt, string hash)
{
  _salt = Convert.FromBase64String(salt);
  _hash = Convert.FromBase64String(hash);
}

Конструктор, принимающий массивы байт, создает копии входных массивов:

        public Password(byte[] salt, byte[] hash)
        {
            _salt = (byte[])salt.Clone();
            _hash = (byte[])hash.Clone();
        }

Конструктор с одним аргументом генерирует случайную синхропосылку и вычисляет хэш предоставленного пароля, используя внутренний метод HashPassword.

        public Password(char[] clearText)
        {
            _salt = GenerateRandom(6);
            _hash = HashPassword(clearText);
        }

Свойства

Реализация свойств тривиальна: все четыре свойства просто возвращают значения соответствующих полей объекта.

        public
        string Salt
{
  get { return Convert.ToBase64String(_salt); }
}

publicstring Hash
{
  get { return Convert.ToBase64String(_hash); }
}

publicstring RawSalt
{
  get { return (byte[])_salt.Clone(); }
}

publicstring RawHash
{
  get { return (byte[])_hash.Clone(); }
}

Методы

Метод Verify сначала вычисляет хэш предоставленного пароля, используя все тот же внутренний метод HashPassword, после чего сравнивает байты сохраненного хэша с байтами только что вычисленного хэша. Совпадение всех до одного байтов хэша означает, что пароль верный.

        public
        bool Verify(char[] clearText)
{
  byte[] hash = HashPassword(clearText);

  if (hash.Length == _hash.Length)
  {
    for (int i = 0; i < hash.Length; i++)
    {
      if (hash[i] != _hash[i])
        returnfalse;
    }

    returntrue;
  }

  returnfalse;
}

Статический метод Generate просто генерирует массив случайных байтов и преобразует его в base64-строку.

        private
        static
        char[] Generate ()
{
  char[] random = newchar[12];

  // генерируем 9 случайных байтов; этого достаточно, чтобы// получить 12 случайных символов из набора base64byte[] rnd = GenerateRandom(9);

  // конвертируем случайные байты в base64
  Convert.ToBase64CharArray(rnd, 0, rnd.Length, random, 0);

  // очищаем рабочий массив
  Array.Clear(rnd, 0, rnd.Length);

  return random;
}

Метод HashPassword тоже не очень сложен. Метод записывает синхропосылку и пароль в поток на основе массива байтов, а затем вычисляет хэш содержимого потока. В качестве хэш-функции используется алгоритм SHA-256.

        private
        byte[] HashPassword(char[] clearText)
{
  Encoding utf8 = Encoding.UTF8;
  byte[] hash;

  // создаем рабочий массив достаточного размера, чтобы вместитьbyte[] data = newbyte[_salt.Length 
              + utf8.GetMaxByteCount(clearText.Length)];

  try
  {
    // копируем синхропосылку в рабочий массив
    Array.Copy(_salt, 0, data, 0, _salt.Length);

    // копируем пароль в рабочий массив, преобразуя его в UTF-8int byteCount = utf8.GetBytes(clearText, 0, clearText.Length,
      data, _salt.Length);

    // хэшируем данные массиваusing (HashAlgorithm alg = new SHA256Managed())
      hash = alg.ComputeHash(data, 0, _salt.Length + byteCount);
  }
  finally
  {
    // очищаем рабочий массив в конце работы, чтобы избежать// утечки открытого пароля
    Array.Clear(data, 0, data.Length);
  }

  return hash;
}

Метод GenerateRandom генерирует массив указанной длины, состоящий из случайных байтов.

        private
        static
        byte[] GenerateRandom(int size)
{
  byte[] random = newbyte[size];
  RandomNumberGenerator.Create().GetBytes(random);
  return random;
}

Как видите, все не так уж и сложно.

Производительность

Производительность, как и безопасность, является важной характеристикой качества приложения. К сожалению, безопасность и производительность часто конфликтуют друг с другом: чем более защищено приложение, тем медленнее оно работает.

В приведенной ниже таблице приведено время выполнения 500 000 вызовов метода Verify, реализованного с использованием различных хэш-функций, а также вариантов их реализации (тестирование производилось на компьютере с процессором Pentium M 1.6 GHz).

Реализация хэш-функции Время выполнения, c
MD5CryptoServiceProvider 60.37
SHA1CryptoServiceProvider 61.03
SHA1Managed 9.45
SHA256Managed 18.69
SHA512Managed 21.83

Как видно из таблицы, варианты реализации с использованием MD5CryptoServiceProvider и SHA1CryptoServiceProvider сильно отстают от остальных вариантов. Причина этого в том, что накладные расходы на создание объектов этих классов гораздо выше, так как они лишь предоставляют интерфейс к соответствующим алгоритмам CryptoAPI.

Если отказаться от создания нового объекта алгоритма хэширования в каждом вызове Verify, а вместо этого использовать один статический объект, то время работы составит 9.58 и 10.37 секунд соответственно. Таким образом, даже в этом случае реализация SHA1 полностью в управляемом коде оказывается быстрее реализации через CryptoAPI (очевидно, сказываются накладные расходы на передачу параметров между управляемым и неуправляемым кодом).

В остальном, чем более безопасный алгоритм хэширования используется, тем больше времени занимает процедура хэширования и проверки пароля. С другой стороны, даже при использовании самого медленного алгоритма, SHA-512, проверка одного пароля занимает около 43 мкс, что является исчезающе малым временем для типичного приложения. Учитывая этот факт, и факт обнаружения уязвимости в алгоритме SHA-1 [4], в качестве алгоритма хэширования для класса Password был выбран SHA-256.

Заключение

Итак, в этой статье вы познакомились с методом безопасного хранения паролей пользователей и его практической реализацией в среде .NET. Библиотека классов .NET Framework предоставляет удобные и простые в использовании криптографические средства, благодаря которым реализация умещается буквально на одной странице. Несмотря на это, поражает количество приложений и Web-сайтов, которые нисколько не заботятся о защите паролей пользователей, сохраняя их в открытом виде. Будем надеяться, что приложение, которое вы разрабатываете, не будет следовать их примеру. До встречи на страницах журнала RSDN Magazine и форумах сайта RSDN!

Литература

  1. Bruce Schneier, Applied Cryptography Second Edition: protocols, algorithms, and source code in C. John Wiley & Sons, Inc., 1996.
  2. Keith Brown, Security Briefs: Mind Those Passwords! MSDN Magazine, July 2004.
  3. Keith Brown, Security Briefs: Password Minder Internals. MSDN Magazine, October 2004.
  4. NIST Brief Comments on Recent Cryptanalytic Attacks on SHA-1 , February, 18, 2005.


Эта статья опубликована в журнале RSDN Magazine #1-2005. Информацию о журнале можно найти здесь
    Сообщений 0    Оценка 600 [+1/-0]         Оценить