Методы хранения паролей

ОГЛАВЛЕНИЕ

Три подхода к хранению паролей объяснены с примерами.

Введение

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

Не обязательно надо предоставлять собственное решение аутентификации по логину и паролю. Альтернативы включают в себя использование Active Directory (в домене Microsoft), Паспорт Microsoft, LDAP (упрощённый протокол доступа к каталогу) для не Microsoft сред и каркас членства, имеющийся в .NET 2.0. Все 4 альтернативы имеют свое место, но если вы хотите понять логику в основе хранения паролей и понять, как правильно реализовать схему управления паролями, то эта статья для вас.

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


Простейший подход – просто хранить

Простейший подход к управлению логинами и паролями – хранить все в виде простого текста (без шифрования и перестановки элементов) в файле или базе данных. Результат был бы таким:

Идентификатор пользователя

Логин

Пароль

101

Bob

Snake

102

David

Rainbow6

103

George

DarkTower

104

Eve

Snake

Плюсы хранения паролей в незашифрованном виде

1.    Подтверждение подлинности (проверка, что пара логина и пароля совпадает с парой в таблице) очень простое – сравниваются строки!
2.    Забытые пароли можно восстановить – пароль легкодоступен, если указан логин.

Минусы хранения паролей в незашифрованном виде

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

Но SQL 2005 поддерживает шифрование – его недостаточно? Нет, шифрования SQL недостаточно. Встроенное шифрование защищает только информацию на диске. Если пользователю разрешено обращаться к данным (выполнять SELECT), SQL автоматически расшифрует информацию. Если веб-приложению разрешено обращаться к данным (как иначе сравнить логин и пароль?), то хакер, взламывающий приложение, может обратиться к данным, получив тот же доступ, что и веб-приложение.

2.    Вторая проблема заключается в том, что во время обмена аутентификационной информацией пароль видим в сети. Если везде не используется защищенная связь, пароль будет виден при прохождении по сети. Например, даже если веб-приложение использует SSL (уровень защищённых сокетов) для отправки пароля, пароль по-прежнему виден, когда сервер веб-приложения выбирает информацию из удаленной базы данных. Результаты запроса передаются по сети незашифрованными.

Допустимо ли хранить пароли в незашифрованном виде?

•    Если хранилище данных зашифровано (например, используется SQL Server 2005),
•    и внутренняя сеть связи сильно защищена (используется только IP-SEC или туннель VPN (виртуальная частная сеть) для связи между серверами),
•    и используется только защищенная связь между клиентом приложения и сервером приложения,
•    и вы уверены, что все работники с доступом к базе данных вообще не совершают ошибок (таких как печать парольной информации или сохранение в файл),
•    и вы уверены, что никто другой не имеет физического доступа к любому из используемых серверов.

Тогда да, хранить пароли в незашифрованном виде нормально.

Шифрование паролей – чуть безопасней

Лучший подход к хранению паролей (и единственная обоснованная альтернатива, если пользователям надо иметь возможность восстанавливать пароли) – шифрование паролей перед их сохранением.

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

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

Если пользователю надо восстановить пароль, сохраненный пароль расшифровывается и доставляется пользователю (обычно по электронной почте).

Идентификатор пользователя

Логин

Пароль

101

Bob

k468dD8F

102

David

56lkV#p6

103

George

8Fk4lVQ0

104

Eve

k468dD8F

Плюсы шифрования с помощью тайны

1.    Забытый или утерянный пароль можно восстановить.
2.    Только одну тайну (алгоритм или ключ) надо хранить безопасно.
3.    Для многопользовательских распределенных приложений при использовании шифрования надо передавать незашифрованный пароль (для проверки) или надо передавать тайну для выполнения аутентификации в клиенте.

Минусы шифрования паролей

1.    Если тайна скомпрометирована, все пароли скомпрометированы. Если у кого-то есть доступ к тайне и к хранилищу паролей, все пароли могут быть расшифрованы!
2.    Только доступа к хранилищу паролей достаточно, чтобы предоставить информацию о паролях, так как все пароли шифруются с помощью одинакового алгоритма. Если два пользователя имеют одинаковый зашифрованный пароль, они также должны иметь одинаковый пароль. Хитрые хакеры с доступом к хранилищу паролей могут создать пользователей с известными паролями и проверить на наличие других пользователей с таким же паролем. Такой тип атаки является разновидностью атаки с известным открытым текстом. Такие атаки можно остановить с помощью соли (смотри ниже).
3.    При использовании блочного шифра длина пароля должна храниться в составе зашифрованного пароля. Надо хранить длину, потому что блочные шифры всегда дают блок зашифрованного текста фиксированного размера. Если длина пароля не зашифрована (например, если хранится как столбец в таблице), информация очень полезна для взломщиков паролей. Знание точной длины пароля сильно упрощает угадывание пароля.

Приемлемо ли такое решение хранения паролей?

Если восстановление утерянного пароля обязательно, то да –  это единственное приемлемое решение. Несколько рекомендаций:

•    Храните тайну в защищенном месте. Жесткое задание тайны в коде приложения – плохое решение. Хранение тайны в файле (даже в файле web.config) – ужасная идея. Если надо использовать тайну, храните ее в базе данных (ограничение доступа путем требования аутентифицированного подключения) или в ключе реестра с ограниченным доступом.
•    Используйте надежный алгоритм шифрования (примеры ниже), не создавайте свой собственный и не используйте тривиальный алгоритм шифрования. Если вы не знаете, что именно делаете, ваш собственный алгоритм может быть очень легко взломать.
•    Используйте соль (смотри ниже), чтобы не дать двум пользователям с одинаковым паролем иметь одинаковый зашифрованный пароль.
•    Выходные данные шифрования двоичные и должны быть закодированы, если хранятся как текст. Если хранение двоичных данных приемлемо, кодирование не требуется, но если зашифрованные пароли должны храниться как текст, обдумайте использование RADIX64 для преобразования двоичных данных в текст. RADIX64 использует символ для каждых 6 битов, увеличивая выходные данные на 33%.

Хранение хешированных паролей – необратимое решение

Криптографическая хеш-функция является необратимой функцией. Хеш-функция принимает входные данные любой длины и генерирует уникальные выходные данные постоянной длины. Например, если пароль (любой длины) хешируется криптографической хеш-функцией MD5, результатом будет 128 битное число, однозначно соответствующее паролю. Криптографические хеши работают не только на паролях – если криптографический хеш двух файлов идентичен, то два файла идентичны.

В последние годы в связи с ростом вычислительных мощностей некоторые криптографические хеш-функции больше не рекомендуется использовать (MD4, MD5, SHA1). Однако их допустимо применять для хеширования паролей. Или же измените код так, чтобы он использовал SHA2.

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

Прелесть необратимой функции заключается в том, что невозможно вычислить пароль на основе хеша. Хешированные пароли неустойчивы к атаке перебором – при наличии словаря и хеша пароля хакер может вычислить хеши всех слов в словаре, сравнить слова с хешем пароля и узнать пароль. Надежные пароли (содержащие буквы, цифры и специальные символы) помогают защититься от атак перебором.

Плюсы хранения хешированных паролей

1.    Оригинальный незашифрованный пароль вообще не хранится. Даже если хранилище пароля скомпрометировано, только хеши становятся общедоступными.
2.    Длина пароля не хранится и не поддается оценке, что сильно затрудняет взлом пароля.
3.    Не нужна тайна, так как никакая тайна не используется для хеширования пароля.
4.    Для многопользовательских распределенных приложений хеш пароля может использоваться для аутентификации. При использовании шифрования приходится передавать незашифрованный пароль (для проверки) или приходится передавать тайну для выполнения аутентификации в клиенте.

Минусы хранения хешированных паролей

1.    Утерянные пароли нельзя восстановить (кроме как использовать методы перебора). Новый пароль приходится создавать и передавать пользователю.
2.    Как и для зашифрованных паролей, если не используется соль (смотри ниже), пользователи с одинаковым паролем будут иметь одинаковый хеш пароля.

Приемлемо ли такое решение хранения паролей?

Да, но следуйте следующим рекомендациям:

•    Используйте надежную криптографическую хеш-функцию. MD5 и SHA1 не рекомендуются. SHA2 – текущий фаворит. Если нужно использовать MD5 или SHA1, используйте более стойкий алгоритм соления.
•    Используйте соль (смотри ниже), чтобы не дать двум пользователям с одинаковым паролем иметь одинаковый зашифрованный пароль.
•    Выходные данные хеширования двоичные и должны быть закодированы, если хранятся как текст. Если хранение двоичных данных приемлемо, кодирование не требуется, но если хешированные пароли должны храниться как текст, обдумайте использование RADIX64 для преобразования двоичных данных в текст. RADIX64 использует символ для каждых 6 битов, увеличивая выходные данные на 33%.


Хранение длины пароля – почему и как

Почему надо хранить длину пароля?

Длина пароля нужна только при шифровке и расшифровке пароля с помощью блочного шифра. Многие блочные шифры имеют размер блока 64 бит – размер зашифрованных данных кратен 8 байтам. Шифрование 12 байтового пароля даст на выходе 16 байт. При расшифровке 16 байт результатом будет 16 байтовая строка с мусором в последних 4 байтах. Если хранится длина пароля, лишнее дополнение можно убрать при расшифровке пароля. Без его удаления любые примитивные попытки сравнить строки провалятся – 12 символьный пароль не совпадает с 16 символьным расшифрованным паролем. Попытка сравнить зашифрованные пароли для проверки на совпадение может провалиться, потому что лишние 4 байта дополнения могут отличаться от шифрования к шифрованию.

Хеширование не требует длины пароля – хеширование 12 символьного пароля с помощью SHA2 всегда дает на выходе 320 битов, и хеширование того же самого пароля всегда дает на выходе те же самые 320 битов.

При использовании алгоритма шифрования, такого как ROT13 (что не рекомендуется), длина зашифрованного пароля совпадает с длиной оригинального пароля. Любой с доступом к хранилищу паролей тогда может получить длину пароля. Если вы решите использовать алгоритм шифрования или поточный шифр (в отличие от блочного шифра), обязательно дополняйте выходные данные, чтобы скрыть длину пароля.

Как можно хранить длину пароля?

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

Решение получше – хранить длину пароля в составе зашифрованной строки. Длину пароля можно добавить к началу строки пароля (например, как первые два символа). При расшифровке пароля по первым двум символам восстанавливается длина, и пароль безопасно обрезается. Хранение длины, зашифрованной вместе с паролем, гарантирует, что никто не может иметь доступ к длине пароля без знания тайны, используемой для шифрования пароля.

Пример – Хранение длины и обрезание паролей

Хранение сообщения вместе с длиной:

// Кодируется длина как первые 4 байта
// Данные находятся в строке «сообщение»
byte[] length = new byte[4];
length[0] = (byte)(message.Length & 0xFF);
length[1] = (byte)((message.Length >> 8) & 0xFF);
length[2] = (byte)((message.Length >> 16) & 0xFF);
length[3] = (byte)((message.Length >> 24) & 0xFF);
csEncrypt.Write(length, 0, 4);
csEncrypt.Write(toEncrypt, 0, toEncrypt.Length);

Восстановление сообщения из двоичного массива:

// Хранилище для незашифрованного сообщения
byte[] fromEncrypt = new byte[encrypted.Length-4];

// Используется для преобразования массива байтов в строку
// с конкретной кодировкой
UTF8Encoding textConverter = new UTF8Encoding();

//Читаются данные из зашифрованного потока.
// Первые 4 байта – фактическая длина
// остальное - сообщение + дополнение
byte[] length = new byte[4];
// Читаются данные из расшифрованного потока
csDecrypt.Read(length, 0, 4);
csDecrypt.Read(fromEncrypt, 0, fromEncrypt.Length);
int len = (int)length[0] | (length[1] << 8) |
          (length[2] << 16) | (length[3] << 24);

//Массив байтов преобразуется обратно в строку.
// Строка обрезается до заданной длины, чтобы удалить
// дополнение
// textConverter по умолчанию - UTF8
return textConverter.GetString(fromEncrypt).Substring(0, len);

Соление – соль улучшает вкус всего

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

Простое соление – использование дополнительного куска данных

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

Пользователь: Bob
Пароль: Snake
Хешированный пароль: hash(“Snake”) -> k468dD8F
Пользователь: Eve
Пароль: Snake
Хешированный пароль: hash(“Snake”) -> k468dD8F

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

Если добавить логин в сочетание:

Пользователь: Bob
Пароль: Snake
Хешированный пароль: hash(“Bob.Snake”) -> 4Fgja93Q
Пользователь: Eve
Пароль: Snake
Хешированный пароль: hash(“Eve.Snake”) -> k468dD8F

Сейчас Боб и Ив имеют разные хеши паролей. Если хакер завладеет хранилищем паролей, теперь хакеру придется вычислить хеш каждого пароля конкретно для каждого пользователя. Хакеру придется заранее вычислить словарные хеши с префиксом “Bob.” для Боба и с префиксом “Eve.” для Ив – нелегкая задача.

Продвинутое соление – использование случайной информации

Использование случайной соли значительно повышает стойкость шифрования паролей и сильно затрудняет взлом перебором.

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

Например:

// Генерируется 6-байтовая соль
public static byte[] GenerateSALT()
{
  byte[] data = new byte[6];
  new System.Security.Cryptography.RNGCryptoServiceProvider().GetBytes(data);
  return data;
}

При первоначальном сохранении пароля для Боба:

1.    Вычислить случайное число (использовать надежный криптографический случайный генератор, такой как в System.Security.Cryptography).
2.    Добавить случайное число к строке пароля.
3.    Вычислить хеш или зашифровать полученную строку.
4.    Сохранить хеш или результат шифрования и случайное число в хранилище паролей.

При сравнении паролей следовать такому же алгоритму:

1.    Достать случайное число из хранилища паролей.
2.    Добавить случайное число к строке пароля.
3.    Вычислить хеш или зашифровать полученную строку.
4.    Сравнить результат с сохраненным хешем или зашифрованным паролем, совпадение означает совпадение паролей.

Использование случайного числа лучше, чем использование логина. Логины не очень случайны – они придерживаются очень строгих правил. Случайные числа (исходящие из надежного криптографического генератора случайных чисел) вносят больше случайности в полученный хеш или зашифрованные выходные данные. Заметьте, что если в качестве соли используется логин, то логин вообще нельзя менять. При изменении логина хеш станет недействительным.

Кодирование двоичных выходных данных в виде текста

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

Две популярные схемы кодирования - UUENCODE (популярна в мире Unix) и Base64 (популярна везде). Base64 – схема кодирования, применяемая для преобразования двоичных вложений для отправки через электронную почту SMTP(простой протокол пересылки почты).

Пример – кодирование двоичного массива в строку

//Получить зашифрованный массив байтов.
byte[] encrypted = msEncrypt.ToArray();
// Преобразовать в строку Base64
string b64 = Convert.ToBase64String(encrypted);

Пример – раскодирование строки в двоичный массив

// Раскодировать Base64
// данные находятся в строке ‘b64’
byte[] encrypted = Convert.FromBase64String(b64);

Алгоритмы шифрования и примеры

Каркас .NET (System.Security.Cryptography) включает в себя встроенную поддержку нескольких алгоритмов шифрования:

•    DES – старый, вышедший из употребления.
•    TripleDES – старый, но все еще стойкий.
•    RC2 – старый, но все еще полезный.
•    Rijndael (AES) - современный.

Пример – кодирование строки:

// Кодируется строка ‘message(сообщение)’
// ScrambleKey и ScambleIV случайно генерируются
// RC2CryptoServiceProvider rc2 = new RC2CryptoServiceProvider();
// rc2.GenerateIV();
// ScrambleIV = rc2.IV;
// rc2.GenerateKey();
// ScrambleKey = rc2.Key
UTF8Encoding textConverter = new UTF8Encoding();
RC2CryptoServiceProvider rc2CSP =
                 new RC2CryptoServiceProvider();

//Данные преобразуются в массив байтов.
byte[] toEncrypt = textConverter.GetBytes(message);

//Получается шифратор.
ICryptoTransform encryptor =
   rc2CSP.CreateEncryptor(ScrambleKey, ScrambleIV);
           
//Шифруются данные.
MemoryStream msEncrypt = new MemoryStream();
CryptoStream csEncrypt = new CryptoStream(msEncrypt,
                         encryptor, CryptoStreamMode.Write);

//Все данные записываются в криптопоток, и он сбрасывается.
//Длина кодируется в виде первых 4 байтов
byte[] length = new byte[4];
length[0] = (byte)(message.Length & 0xFF);
length[1] = (byte)((message.Length >> 8) & 0xFF);
length[2] = (byte)((message.Length >> 16) & 0xFF);
length[3] = (byte)((message.Length >> 24) & 0xFF);
csEncrypt.Write(length, 0, 4);
csEncrypt.Write(toEncrypt, 0, toEncrypt.Length);
csEncrypt.FlushFinalBlock();

//Получается зашифрованный массив байтов.
byte[] encrypted = msEncrypt.ToArray();

Пример – раскодирование строки:

// Раскодируется зашифрованный byte[]
UTF8Encoding textConverter = new UTF8Encoding();
RC2CryptoServiceProvider rc2CSP = new RC2CryptoServiceProvider();
//Получается дешифратор, использующий тот же ключ и проверку идентичности, что и шифратор.
ICryptoTransform decryptor =
       rc2CSP.CreateDecryptor(ScrambleKey, ScrambleIV);

//Дешифруется ранее зашифрованное сообщение с помощью дешифратора,
// полученного в шаге выше.
MemoryStream msDecrypt = new MemoryStream(encrypted);
CryptoStream csDecrypt = new CryptoStream(msDecrypt,
                         decryptor, CryptoStreamMode.Read);

byte[] fromEncrypt = new byte[encrypted.Length-4];

//Читаются данные из криптопотока.
byte[] length = new byte[4];
csDecrypt.Read(length, 0, 4);
csDecrypt.Read(fromEncrypt, 0, fromEncrypt.Length);
int len = (int)length[0] | (length[1] << 8) |
          (length[2] << 16) | (length[3] << 24);

//Массив байтов преобразуется обратно в строку.
return textConverter.GetString(fromEncrypt).Substring(0, len);

Алгоритмы хеширования и примеры

Каркас .NET (System.Security.Cryptography) включает в себя встроенную поддержку нескольких криптографических хеш-функций:

•    MD5 – взломан, старайтесь избегать.
•    RIPEMD160 – приемлем, вообще не популярен в США.
•    SHA1 – приемлем, но 160 битные выходные данные считаются слишком короткими.
•    SHA256 – рекомендуемый минимум.
•    SHA384 (SHA2) - рекомендуется.
•    SHA512 – очень надежен, но не обеспечивает дополнительной защиты в сравнении с SHA384.

Пример – хеширование строки:

public byte[] EncryptPassword(string userName, string password, 
       int encryptionVersion, byte[] salt1, byte[] salt2)
{
    string tmpPassword = null;

    switch(encryptionVersion)
    {
        case 2: // пароль + много соли
            tmpPassword = Convert.ToBase64String(salt1)
             + Convert.ToBase64String(salt2)
             + userName.ToLower() + password;
            break;
        case 1: // логин в качестве соли
            tmpPassword  = userName.ToLower() + password;
            break;
        case 0: // без соли
        default:
            tmpPassword = password;
            break;
    }

    //Строка пароля преобразуется в массив байтов.
    UTF8Encoding textConverter = new UTF8Encoding();
    byte[] passBytes = textConverter.GetBytes(tmpPassword);

    //Возвращаются зашифрованные байты
    if (encryptionVersion == 2)
        return new SHA384Managed().ComputeHash(passBytes);
    else
        return new MD5CryptoServiceProvider().ComputeHash(passBytes);
}

Пример – сравнение двух хешей на равенство:

// Сравниваются два массива на равенство
// Можно добавить сравнение длины, но обычно
// размер всех хешей одинаков.
private bool PasswordsMatch(byte[] psswd1, byte[] psswd2)
{
    try
    {
        for(int i = 0; i < psswd1.Length; i++)
        {
            if(psswd1[i] != psswd2[i])
                return false;
        }
        return true;
    }
    catch(IndexOutOfRangeException)
    {
        return false;
    }
}

Заключение

У вас уже есть достаточно информации, чтобы принимать обоснованные решения насчет хранения паролей.

Простые рекомендации:

•    Если надо восстанавливать пароли, используйте шифрование.
•    Если не надо восстанавливать пароли, используйте хеши (более безопасно).
•    Что бы вы ни делали, солите пароли.