Источник: rsdn.ru/article/crypto/cryptoapi.xml

R U S S I A N   S O F T W A R E   D E V E L O P E R   N E T W O R K
 

Хеширование, шифрование и цифровая подпись с использованием CryptoAPI и .NET

Введение

Криптография, как прикладная дисциплина, существует уже очень давно. Один из простейших шифров, шифр алфавитной замены, использовался еще во времена Цезаря. Но настоящий расцвет криптографии произошел только в последние несколько столетий, когда к задачам шифрования был применен математический аппарат. Эта статья дает обзор криптографических алгоритмов, предоставляемых MS CryptoAPI 2.0 и .NET Framework, и содержит примеры их использования на языках C++ (CryptoAPI) и C# (.NET). Также вкратце описывается набор замечательных ошибок, обнаруженных автором в реализации класса RSACryptoServiceProvider библиотеки .NET Framework.

Симметричные и асимметричные шифры

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

Сначала дадим краткое определение криптографических функций шифрования и дешифрования.

Пусть есть две функции E(M1,K1) и D(M2,K2), зависящие от сообщений M1 и M2 и ключей K1 и K2, такие, что D(E(M,K1),K2)=M для некоторой пары ключей K1 и K2 и любого M, тогда E(.) и D(.) – функции шифрования и дешифрования, соответственно. Результат C=E(M,K1) называется криптограммой (или шифрограммой).

К функциям E(.) и D(.) предъявляются следующие требования:

  1. Они должны быть легко вычислимы для любых M, если известны K1 и K2.
  2. Вычисление D(M,?) – тяжелая задача при неизвестном ключе K2 (т.е., не зная ключа дешифрования, мы не можем вычислить исходное сообщение по криптограмме).
  3. Вычисление ключей K1 и K2 – тяжелая задача при наличии некоторого набора пар {M,E(M,K1)} (имея набор криптограмм и исходных сообщений, мы не можем вычислить ключи).
  4. Вычисление K2 (в случае K2 отличного от K1) при известном K1 – также должно быть трудной задачей (это требование относится к асимметричным шифрам. Для цифровой подписи K2 и K1 меняются ролями).

Надежность шифра определяется именно по его соответствию вышеперечисленным требованиям, при этом считается, что алгоритмы вычисления E(.) и D(.) известны всем (есть еще вариант так называемой Security by obscurity, т.е. защиты из-за неизвестности алгоритма, но он при оценке стойкости шифров не рассматривается).

Если K1=K2, то шифр называется симметричным, в противном случае – асимметричным. Примеры известных симметричных шифров – DES, IDEA, Blowfish, ГОСТ, асимметричных – RSA, ECC.

Шифры также бывают блочными и потоковыми. Блочный шифр работает с сообщениями фиксированного размера (например, 64 бита), а поточный – шифрует весь поток данных посимвольно (например, побайтно). Известные блочные шифры – DES, IDEA, Blowfish, потоковые – RC4. Блочность шифра не означает невозможность шифрования им сообщений, превышающих по длине размер блока.

Существуют следующие распространенные варианты использования блочных шифров для шифрования длинных сообщений:

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

Однако стоит немного остановиться на асимметричных шифрах, а точнее на популярной их разновидности – шифрах с открытым ключом. Шифр с открытым ключом имеет два ключа – открытый (public), который можно свободно распространять, и закрытый (private), который держится в секрете. Зашифровать сообщение может кто угодно, но расшифровать его сможет только владелец закрытого ключа. Системы шифрования с открытым ключом, как правило, основываются на сложности разрешения какой-либо математической задачи. Например, популярный алгоритм RSA основан на сложности разложения на множители произведения двух больших простых чисел. Рассмотрим этот алгоритм поподробнее, т.к. понимание основ его работы нам пригодится в дальнейшем.

Алгоритм RSA

Для генерации ключевой пары выбираются два больших (по современным требованиям надежности не менее 512 бит) простых числа (некоторые популярные алгоритмы их генерации рассмотрены в [1]) p и q. Затем вычисляется их произведение n=p*q и выбирается случайное число e, так что e взаимно просто с (p-1)*(q-1). Также вычисляется число d, такое, что d*e=1 mod (p-1)*(q-1) (эта операция делается с помощью расширенного алгоритма Евклида, см. [1]). Это означает, что

d*e=1+k*(p-1)*(q-1), где k – некое целое число. Открытым ключом является пара e и n, закрытым – d и n. Алгоритм позволяет шифровать сообщения m меньшие, либо равные n (как целые неотрицательные числа). Большие сообщения следует разбивать на части.

Шифрование осуществляется следующим способом:

c=m^e mod n (возведение m в степень e по модулю n),

а дешифрование производится так:

m=c^d mod n.

Гарантию того, что получится верный результат, дает малая теорема Ферма. Для любого a меньше, либо равного n:

a^phi(n)=1 mod n, где phi(n) – число целых положительных чисел меньших n и взаимно простых с ним. Для n=p*q, phi(n)=(p-1)*(q-1) и

(m^e)^d mod n = m^(1+k*phi(n)) mod n=m.

Хеши

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

Для любых сообщений m, h=H(m) легко вычисляема.

Задача нахождения такого u (отличного от m), чтобы H(u)=h, должна являться трудной при неизвестном m.

Задача нахождения такого u, что H(u)=H(m) является трудной при известном m.

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

Цифровая подпись

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

Обмен ключами

Хорошо, у нас есть работающий шифр (или система ЭЦП), который мы хотим использовать для защищенной передачи данных. Однако остается одна проблема – обмен ключами. Симметричному алгоритму нужен один и тот же ключ как на шифрующей, так и на дешифрующей стороне. Требуется надежный способ генерации одинаковых сеансовых ключей на обеих сторонах или передачи ключа шифрующей стороной дешифрующей стороне (в качестве такого способа может выступать надежный курьер с дискеткой). Существуют специальные алгоритмы генерации совпадающих сеансовых ключей, но мы их рассматривать не будем, т.к. в CryptoAPI и .NET они не используются. Рассмотрим, как можно передать сгенерированный на шифрующей стороне сеансовый ключ другой стороне. Самый простой вариант – использовать шифр с открытым ключом. Имея открытый ключ получателя, мы шифруем на нем сеансовый ключ и передаем результат получателю вместе с зашифрованными данными. Теперь получатель может расшифровать сеансовый ключ и сами данные. Казалось бы, все хорошо, однако тут кроется еще одна проблема, связанная с распределением открытых ключей. В самом деле, где гарантия того, что полученный открытый ключ действительно принадлежит конкретному получателю? Если злоумышленник перехватил оригинальный открытый ключ и подсунул вместо него свой, то зашифрованный сеансовый ключ получит именно он, а не требуемый получатель. В дальнейшем злоумышленник может выдать себя за получателя и перехватить передаваемое сообщение. Для предотвращения такой возможности вводят различные сети доверия (trusted network) и авторитетные источники (trusted authority), которые берут на себя ответственность за подлинность открытого ключа (ключ подписывается собственной подписью центра, которой все безоговорочно доверяют).

Работа с CryptoAPI

Криптопровайдеры, инициализация и деинициализация

Любой сеанс работы с CryptoAPI начинается с инициализации (получения контекста). Инициализация выполняется при помощи функции CryptAcquireContext. В качестве параметров эта функция принимает имя контейнера ключей, имя криптопровайдера, тип провайдера и флаги, определяющие тип и действия с контейнером ключей и режим работы криптопровайдера:

BOOL WINAPI CryptAcquireContext(HCRYPTPROV* phProv,LPCTSTR pszContainer,
  LPCTSTR pszProvider,DWORD dwProvType,DWORD dwFlags);

Криптопровайдер – это сущность (обычно библиотека), реализующая определенный набор криптографических алгоритмов и обеспечивающая работу с ними. Существует около семи стандартных провайдеров, предустановленных в системе. Нам для примеров понадобятся два из них – Microsoft Base Cryptographic Provider (MS_DEF_PROV) и Microsoft Enhanced Cryptographic Provider (MS_ENHANCED_PROV).

ПРИМЕЧАНИЕ

Заметим, что Enhanced-провайдер присутствует только на тех машинах, где установлена поддержка 128-битного шифрования (она автоматически устанавливается вместе с Internet Explorer 6.0).

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

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

ПРИМЕЧАНИЕ

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

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

Для первоначального создания контейнера нужно вызвать CryptAcquireContext с флагом CRYPT_NEWKEYSET. Для удаления контейнера требуется указать флаг CRYPT_DELETEKEYSET.

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

Следующий пример демонстрирует инициализацию CryptoAPI для последующего вычисления хеша MD5:

HCRYPTPROV hProv;
if(!CryptAcquireContext(&hProv, NULL, MS_DEF_PROV, PROV_RSA_FULL,
                        CRYPT_VERIFYCONTEXT))
{
    MessageBoxA("Failed to acquire cryptographic context",
                "Error", MB_OK|MB_ICONERROR);
    return;
}
//здесь что-то делаем
//...
CryptReleaseContext(hProv,0);

Деинициализация CryptoAPI выполняется с помощью функции CryptReleaseContext, единственным значащим параметром которой является полученный ранее хэндл криптографического контекста.

Генерация ключей и обмен ключами

Для генерации ключей в CryptoAPI предусмотрены две функции – CryptGenKey и CryptDeriveKey. Первая из них генерирует ключи случайным образом, а вторая – на основе пользовательских данных. При этом гарантируется, что для одних и тех же входных данных CryptDeriveKey всегда выдает один и тот же результат. Это способ генерации ключей может быть полезен для создания симметричного ключа шифрования на базе пароля. Мы же более подробно остановимся на функции CryptGenKey, которая используется чаще всего. Эта функция имеет прототип:

BOOL WINAPI CryptGenKey(HCRYPTPROV hProv, ALG_ID Algid, DWORD dwFlags,
                        HCRYPTKEY* phKey);

Первый и четвертый параметры говорят сами за себя. Вторым параметром передается идентификатор алгоритма шифрования, для которого генерируется ключ (например, CALG_3DES). При генерации ключевых пар RSA для шифрования и подписи используются специальные значения AT_KEYEXCHANGE и AT_SIGNATURE. Третий параметр задает различные опции ключа, которые зависят от алгоритма и провайдера. Например, старшие 16 битов этого параметра могут задавать размер ключа для алгоритма RSA. Подробное описание всех флагов можно найти в MSDN. Здесь я упомяну только один флаг - CRYPT_EXPORTABLE, который позволяет экспортировать закрытые ключи RSA из контейнера (по умолчанию это запрещено). Для других ключей этот параметр смысла не имеет – открытые ключи являются свободно экспортируемыми, а сессионные ключи вообще не хранятся внутри контейнера, т.ч. их обязательно нужно экспортировать.

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

Обмен ключами в CryptoAPI реализуется с помощью функций CryptExportKey и CryptImportKey, имеющих следующие прототипы:

BOOL WINAPI CryptExportKey(HCRYPTKEY hKey,HCRYPTKEY hExpKey,DWORD dwBlobType,DWORD dwFlags,BYTE* pbData,DWORD* pdwDataLen);

BOOL WINAPI CryptImportKey(HCRYPTPROV hProv,BYTE* pbData,DWORD dwDataLen,HCRYPTKEY hImpKey,DWORD dwFlags,HCRYPTKEY* phKey);

В качестве ключей экспорта/импорта могут использоваться либо ключевая пара RSA (с типом AT_KEYEXCHANGE), либо симметричный сеансовый ключ. Параметр dwBlobType зависит от того, какой ключ экспортируется (импортируется), и задает тип структуры, в которую помещается экспортируемый ключ. Для открытого ключа это PUBLICKEYBLOB, и ключ экспорта/импорта при этом лишен смысла и должен быть нулем. Для закрытого ключа это PRIVATEKEYBLOB, и в качестве ключа экспорта может использоваться сеансовый ключ. Для сеансового ключа это обычно SIMPLEBLOB (бывает еще OPAQUEKEYBLOB и SYMMETRICWRAPKEYBLOB, но мы их рассматривать не будем), а экспортируется он, как правило, на открытом ключе получателя.

Описание флагов можно найти в MSDN. Я выделю среди них флаг CRYPT_OAEP, который заставляет криптопровайдера использовать формат PKCS #1 версии 2 при сохранении сессионного ключа с шифрованием RSA. Ключ, сохраненный в этом формате, затем может быть расшифрован другими системами шифрования (например, я так передавал ключ в библиотеку Crypto++). Если же этот флаг не указан, то ключ сохраняется в каком-то ведомом только CryptoAPI формате, и использовать его где-либо еще вряд ли удастся.

И, наконец, параметры pbData и pdwDataLen задают адрес и размер буфера под структуру экспортируемого ключа. Если размер структуры не известен при написании программы, то можно определить требуемый размер буфера, установив в pbData ноль. В этом случае нужный размер возвращается в pdwDataLen.

После окончания работы с ключом, его нужно уничтожить вызовом CryptDestroyKey.

ПРИМЕЧАНИЕ

При этом закрытый ключ сохраняется в контейнере (если, конечно, не использовался режим CRYPT_VERIFYCONTEXT), а сессионные ключи уничтожаются совсем.

В качестве примера рассмотрим создание и экспорт пары ключей для шифрования RSA и симметричного ключа 3DES:

//создание и экспорт пары ключей  RSA
if(CryptGenKey(hProv,AT_KEYEXCHANGE,1024<<16,&hKey)) //генерируем 1024-битный ключ
{
    RSAPubKey1024 key; //эта структура описана в файле tools.h в примере rsakg
    DWORD dwLen=sizeof(RSAPubKey1024);
    if(CryptExportKey(hKey,NULL,PUBLICKEYBLOB,0,(BYTE *)&key,&dwLen)) //экспортируем ключ
    {
        //... тут что-то делаем
    }
    CryptDestroyKey(hKey); //уничтожаем ключ
}

//генерация и экспорт ключа 3DES
if(::CryptGenKey(hProv,CALG_3DES,CRYPT_EXPORTABLE,&hKey)) //генерируем ключ для 3DES
{
    RSA1024KeyExchBLOB kb; //описание см. в tools.h
    dwLen=sizeof(RSA1024KeyExchBLOB);
    if(::CryptExportKey(hKey,hPubKey,SIMPLEBLOB,0,(BYTE *)&kb,&dwLen)) //экспортируем ключ 3DES на публичном ключе RSA
    {
        //...
    }
}

Рабочие примеры генерации и импорта/экспорта ключей приведены в демонстрационных проектах rsakg и encfile.

Симметричные шифры DES и 3DES

Это одни из немногих симметричных шифров, предоставляемых стандартными криптопровайдерами CryptoAPI. Поскольку DES и 3DES – это практически один и тот же алгоритм (3DES – это DES, применяемый 3 раза подряд), то мы ограничимся примером использования алгоритма 3DES.

ПРИМЕЧАНИЕ

Однако заметим, что для использования алгоритма 3DES требуется Enhanced провайдер, а для DES вполне достаточно Base. Впрочем, DES уже не является стойким по современным меркам алгоритмом, поэтому использовать его стоит лишь там, где надежность шифрования не очень критична.

Алгоритм 3DES использует разные ключи DES для каждой из своих итераций. Поэтому размер его ключа равен тройному размеру ключа DES, т.е. 192 (64*3) бита. Реально размер ключа 3DES – 168 (56*3) бит, т.к. в DES один байт ключа является контрольным для основных семи. Шифрование и дешифрование выполняются с помощью функций:

BOOL WINAPI CryptEncrypt(HCRYPTKEY hKey,HCRYPTHASH hHash,BOOL Final,DWORD dwFlags,BYTE* pbData,DWORD* pdwDataLen,DWORD dwBufLen);

BOOL WINAPI CryptDecrypt(HCRYPTKEY hKey,HCRYPTHASH hHash,BOOL Final,DWORD dwFlags,BYTE* pbData,DWORD* pdwDataLen);

Параметр hHash позволяет параллельно с шифрованием/дешифрованием проводить хеширование данных для последующей электронной подписи или ее проверки. Флаг Final определяет, является ли шифруемый блок данных последним. Он необходим, поскольку данные можно шифровать по кускам, но для последнего блока всегда выполняется определенная деинициализация алгоритма (освобождаются внутренние структуры), и многие алгоритмы производят добавление (и проверку корректности при дешифровании) заполнителя (padding) после основных данных. Параметры pbData и pdwDataLen задают адрес буфера и размер шифруемых данных. Для не последнего блока данных (Final=FALSE) размер данных должен быть всегда кратен размеру шифруемого алгоритмом блока (для 3DES и DES этот размер равен 64 битам). Для последнего блока допускается нарушение этого условия.

ПРИМЕЧАНИЕ

Заметим, что зашифрованные данные помещаются в тот же самый буфер поверх исходных.

Последний параметр функции CryptEncrypt dwBufLen может показаться странным. Зачем нам размер буфера, если мы и так знаем размер входных данных? Однако на самом деле он необходим. Как я уже упомянул, многие алгоритмы добавляют заполнитель в последний блок после основных данных. В этом случае размер зашифрованных данных может оказаться больше, чем размер исходных данных. И результат может попросту не вместиться в буфер! Поэтому стоит заранее указать размер буфера, превышающий максимальный размер помещаемых в него открытых данных. Для DES и 3DES максимально возможный довесок составляет 64 бита, т.е. 8 байт (это я установил опытным путем).

В качестве примера шифрования приведу выдержку из демонстрационного проекта encfile:

BYTE buf[BUFFER_SIZE+8]; //8 - запас на padding
while(fSize)
{
    if(!::ReadFile(hInFile,buf,BUFFER_SIZE,&dwLen,NULL)) //читаем блок данных
        break;
    dwSzLow=dwLen;
    if(!::CryptEncrypt(hKey,hHash,fSize<=BUFFER_SIZE,0,buf,&dwSzLow,sizeof(buf))) //шифруем и хешируем его
    break;
    if(!::WriteFile(hOutFile,buf,dwSzLow,&dwSzLow,NULL))
        break;
    fSize-=dwLen;
}

Асимметричный шифр RSA

Начиная с Windows 2000 расширенный (Enhanced) криптопровайдер поддерживает прямое шифрование данных по алгоритму RSA. Максимальный размер данных, которые можно зашифровать за один вызов CryptEncrypt, равен размеру ключа минус 11 байт. Дело в том, что при шифровании добавляется обязательный заполнитель (padding), который впоследствии проверяется при дешифрации. Соответственно, использование шифра RSA может быть целесообразно только при небольших объемах шифруемых данных (например, при обмене ключами) из-за существенного увеличения объема шифрованного текста и относительно медленной работе алгоритма RSA по сравнению с блочными шифрами.

Хеши MD5 и SHA

Хеш создается вызовом функции CryptCreateHash, принимающей на входе контекст криптопровайдера, идентификатор алгоритма (CALG_MD5 или CALG_SHA) и хендл ключа (для хешей с ключем типа MAC и HMAC). После этого хеш можно вычислять как явно, используя функцию CryptHashData, так и неявно, передавая хэндл хеша в функцию CryptEncrypt. Использование CryptEncrypt обсуждалось в разделе про DES, поэтому остановимся на функции CryptHashData. Ее вызов может выглядеть следующим образом:

CryptHashData(hHash,(BYTE *)&data,dwLen,0);

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

CryptGetHashParam(hHash,HP_HASHVAL,(BYTE *)&buf,&dwLen,0);

Размер хеша MD5 равен 128 бит или 16 байтов. Для SHA это 160 бит или 20 байтов. После получения значения хеш использовать уже нельзя. Его нужно разрушить вызовом CryptDestroyHash. Проверка хеша производится точно также, как и его создание – нужно вычислить хеш и сверить полученное значение с сохраненным:

HCRYPTHASH hHash;
::CryptCreateHash(hProv,CALG_MD5,0,0,&hHash);
::CryptHashData(hHash,(BYTE *)&data,dwLen,0);
BYTE newHash[16];
dwLen=16;
::CryptGetHashParam(hHash,HP_HASHVAL,newHash,&dwLen,0);
if(!memcmp(newHash,oldHash,16))
{
    //хеш верен
}
else
{
    //хеш не верен
}
::CryptDestroyHash(hHash);

Алгоритмы цифровой подписи RSA и DSS

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

 //Вычисление подписи
::CryptSignHash(hHash,AT_SIGNATURE,NULL,0,buf,&dwLen);
// Проверка подписи
if(::CryptVerifySignature(hHash,buf,1024/8,hPubKey,NULL,0))
{
  // Верная подпись
}
else
{
  // Неверная подпись или хеш
}
Размер подписи равняется размеру ключа RSA или DSS, соответственно.

Практический пример применения подписи можно увидеть в демонстрационном проекте encfile. Вычисление/проверка подписи RSA и DSS выполняется одинаково, однако для работы с подписью DSS нужно использовать Microsoft DSS Cryptographic Provider (MS_DEF_DSS_PROV,тип PROV_DSS). Кроме того, подпись DSS работает только с алгоритмом хеширования SHA.

Криптографические классы в .NET

Большая часть криптографических классов (не абстрактных) .NET базируется на криптопровайдерах CryptoAPI. Однако имеются и исключения (например, SHA256Managed). В принципе, иерархия классов .NET позволяет абстрагироваться от конкретной реализации алгоритма, что может обеспечить простой переход на не привязанные к CryptoAPI классы в будущем. К сожалению, документация .NET по криптографическим классам, мягко говоря, оставляет желать лучшего. Поэтому часть из написанного далее следует воспринимать как собственные мысли и догадки автора (иногда полученные в ходе активного наступания на грабли).

Симметричные шифры DES, 3DES, Rijndael

Симметричные блочные шифры представлены в .NET классами DESCryptoServiceProvider, TripleDESCryptoServiceProvider, RijndaelManaged. Все эти классы являются потомками абстрактного класса SymmetricAlgorithm, описывающего все семейство блочных алгоритмов с симметричными ключами. Класс описывает свойства, позволяющие манипулировать основными параметрами алгоритма: размером блока, режимом работы, инициализационным вектором, ключом и другими. И методы CreateEncryptor и CreateDecryptor, возвращающие контексты (интерфейс ICryptoTransform) для криптографических трансформаций данных. Также имеются методы GenerateKey и GenerateIV для генерации ключей и инициализационных векторов. Конкретные реализации наследуются от этого класса (возможно, через другие абстрактные классы – например, DESCryptoServiceProvider наследуется от класса DES, унаследованного от SymmetricAlgorithm). Использование симметричных шифров демонстрируется в примере symmetric. Собственно шифрование выполняется следующим кодом:

private SymmetricAlgorithm alg;

alg=(SymmetricAlgorithm)RijndaelManaged.Create(); //пример создания класса RijndaelManaged

PasswordDeriveBytes pdb=new PasswordDeriveBytes(Password.Text,null); //класс, позволяющий генерировать ключи на базе паролей
pdb.HashName="SHA512"; //будем использовать SHA512
int keylen=(int)KeySize.SelectedItem; //получаем размер ключа из ComboBox’а
alg.KeySize=keylen; //устанавливаем размер ключа
alg.Key=pdb.GetBytes(keylen>>3); //получаем ключ из пароля
alg.Mode=CipherMode.CBC; //используем режим CBC
alg.IV=new Byte[alg.BlockSize>>3]; //и пустой инициализационный вектор
ICryptoTransform tr=alg.CreateEncryptor(); //создаем encryptor

FileStream instream=new FileStream(inFile.Text,FileMode.Open,FileAccess.Read,FileShare.Read);
FileStream outstream=new FileStream(outFile.Text,FileMode.Create,FileAccess.Write,FileShare.None);
int buflen=((2<<16)/alg.BlockSize)*alg.BlockSize;
byte []inbuf=new byte[buflen];
byte []outbuf=new byte[buflen];
int len;
while((len=instream.Read(inbuf,0,buflen))==buflen)
{
    int enclen=tr.TransformBlock(inbuf,0,buflen,outbuf,0); //собственно шифруем
    outstream.Write(outbuf,0,enclen);
}
instream.Close();
outbuf=tr.TransformFinalBlock(inbuf,0,len); //шифруем финальный блок
outstream.Write(outbuf,0,outbuf.Length);
outstream.Close();
alg.Clear(); //осуществляем зачистку

Как можно видеть, ничего сложного в процессе шифрования/дешифрования нет. Использование базового класса SymmetricAlgorithm позволяет свести всю привязку к конкретному алгоритму к одной строчке – созданию экземпляра класса нужного алгоритма.

ПРЕДУПРЕЖДЕНИЕ

Стоит, однако, обратить внимание на необходимость явного задания инициализационного вектора (IV), поскольку, в отличие от CryptoAPI, он не инициализируется нулем по умолчанию, а выбирается случайно.

На вторые возможные грабли я наступил в коде дешифрования. Размер расшифрованных данных для не финального блока может быть меньше размера шифротекста. Ни с чем подобным в CryptoAPI я не сталкивался.

Наряду с самостоятельным чтением файла и шифрованием его по блокам, возможен еще вариант с использованием стрима CryptoStream. При этом вы создаете CryptoStream, передавая ему на входе стрим для записи и интерфейс трансформации, а затем просто пишете в этот стрим свои данные. Этот вариант описан в документации по .NET Framework, поэтому я не буду подробно на нем останавливаться.

Асимметричные шифры и цифровая подпись: RSA и DSA

Аналогично симметричным шифрам, все классы асимметричных шифров в .NET унаследованы от абстрактного базового класса AsymmetricAlgorithm. Конкретная реализация представлена классами RSACryptoServiceProvider и DSACryptoServiceProvider. Увы, здесь базовый класс уже не обеспечивает стандартных механизмов шифрования/дешифрования и ЭЦП, т.ч. приходится иметь дело с конкретной реализацией. И тут дело опять не обошлось без сюрпризов. Первым сюрпризом является то, что нет стандартного способа сохранить приватный ключ RSA в файл. Мне пришлось самому организовывать шифрование ключа по паролю. Вторым, гораздо более неприятным, сюрпризом оказывается факт, что

ПРЕДУПРЕЖДЕНИЕ

Заявленные в классах RSACryptoServiceProvider и DSACryptoServiceProvider методы SignHash не работают. Вообще! При попытке передать в них хеш, сформированный с помощью MD5CryptoServiceProvider или SHA1CryptoServiceProvider, всегда вываливается исключение.

Поэтому приходится использовать классы RSAPKCS1SignatureFormatter и DSASignatureFormatter.

Третий (но не последний) веселый сюрприз – в описании метода Encrypt класса RSACryptoServiceProvider можно найти упоминания о максимальном размере куска данных, который можно зашифровать. Там утверждается, что для Win2k с установленным 128-битным шифрованием этот размер равен <размер ключа>/8-11 байтов.

ПРЕДУПРЕЖДЕНИЕ

Не верьте этому! При попытке подсунуть блок данных длиной в 117 байт при длине ключа 1024 бита вы получите исключение. У меня получилось, что максимальный размер блока данных при таком размере ключа равен 87 байтам, т.е. на 30 байт меньше заявленного.

Последние два сюрприза чуть не повергли меня в состояние глубокой депрессии, т.к. я уже почти решил, что класс RSACryptoServiceProvider неработоспособен в принципе. Вот эти два перла из коллекции ошибок класса RSACryptoServiceProvider.

ПРЕДУПРЕЖДЕНИЕ

Метод ImportParameters, принимающий на входе структуру RSAParameters, содержащую публичный или приватный ключ, модифицирует эту структуру, хотя по нормальной человеческой логике и по документации он не имеет права этого делать.

Метод Decrypt, обратный методу Encrypt, не может расшифровать зашифрованный блок данных, если его первоначальный размер был больше 86 байтов (для 1024-битных ключей). Т.е. вы можете зашифровать блок из 87 байтов, но потом вы не сможете его расшифровать.

В общем, по моему нескромному мнению, классу RSACryptoServiceProvider нужно присудить заслуженную премию, как самому кривому из всех классов .NET. Тем не менее, хорошо то, что хорошо кончается. Я все-таки сумел найти и обойти все эти ошибки, и на свет все-таки родился пример (см. Asymmetric.zip), демонстрирующий прямое шифрование RSA, цифровую подпись RSA и DSA и хеширование в .NET.

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

//генерация ключей: новый ключ генерируется автоматически при создании экземпляра класса
RSACryptoServiceProvider rsa=new RSACryptoServiceProvider(1024); //1024-битный ключ

//Экспорт ключа
RSAParameters rp=rsa.ExportParameters(true); //экспорт закрытого ключа. Для открытого нужно передавать false

//Импорт ключа
rsa.ImportParameters(rp); //будьте внимательны. Этот вызов портит значение rp

//шифрование блока
byte []enc=rsa.Encrypt(buf,true); //шифрование RSA/OAEP

//дешифрование блока
rsa.Decrypt(enc,true); //дешифрация RSA/OAEP

//создание цифровой подписи RSA
AsymmetricSignatureFormatter sf;
sf=(AsymmetricSignatureFormatter)new RSAPKCS1SignatureFormatter(rsa); //создаем форматер
sf.SetHashAlgorithm("MD5"); //выбираем алгоритм хеширования
sig=sf.CreateSignature(Hash); //создаем подпись (хеш должен быть уже посчитан ранее)

//проверка цифровой подписи RSA
AsymmetricSignatureDeformatter df;
df=(AsymmetricSignatureDeformatter)new RSAPKCS1SignatureDeformatter(rsa); //создаем деформатер
df.SetHashAlgorithm("MD5");
if(df.VerifySignature(Hash,sig)) //проверяем подпись
{
    //подпись верна
}
else
{
    //подпись неверна
}

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

Обмен симметричными ключами

За обмен сессионными ключами в .NET отвечают классы RSAOAEPKeyExchangeFormatter/Deformatter и RSAPKCS1KeyExchangeFormatter/Deformatter. Они унаследованы от базовых классов AsymmetricKeyExchangeFormatter/Deformatter, предоставляющих методы CreateKeyExchange и DecryptKeyExchange для шифрования и дешифрации сессионных ключей, соответственно. Рассмотрим передачу сессионного ключа на небольшом примере:

RSACryptoServiceProvider rsa1=new RSACryptoServiceProvider(1024); //получатель ключа
RSAParameters rp=rsa1.ExportParameters(false);
Console.WriteLine("Passing public key to sender...");
//передаем открытый ключ отправителю
//..

RSACryptoServiceProvider rsa2=new RSACryptoServiceProvider(1024); //отправитель ключа
Console.WriteLine("Importing receiver's public key...");
//импортируем открытый ключ получателя
rsa2.ImportParameters(rp);
AsymmetricKeyExchangeFormatter kf=(AsymmetricKeyExchangeFormatter)new RSAOAEPKeyExchangeFormatter(rsa2);
byte []key=new Byte[16]; //128-битный ключ
byte []enckey=kf.CreateKeyExchange(key);
Console.WriteLine("Sending encrypted session key to receiver...");
//передаем зашифрованный сессионный ключ получателю
//...

AsymmetricKeyExchangeDeformatter kd=(AsymmetricKeyExchangeDeformatter)new RSAOAEPKeyExchangeDeformatter(rsa1);
//Расшифровываем ключ
byte []deckey=kd.DecryptKeyExchange(enckey);
for(uint i=0;i<16;i++)
if(deckey[i]!=key[i])
{
    Console.WriteLine("Key exchange failed");
    return;
}
Console.WriteLine("Key exchange succeeded");

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

Хеши

Все классы хешей MD5CryptoServiceProvider, SHA1CryptoServiceProvider, SHA1Managed, и т. д. в .NET унаследованы от базового класса HashAlgorithm, предоставляющего два различных пути для вычисления хеша – метод ComputeHash, вычисляющий хеш блока данных или потока за один раз, и методы TransformBlock и TransformFinalBlock, позволяющие вычислить хеш, разбивая данные на части. Вычисление хеша при помощи ComputeHash выглядит следующим образом:

HashAlgorithm ha=(HashAlgorithm)new MD5CryptoServiceProvider();
byte []hash=md5.ComputeHash(data,0,data.Length); //считаем хеш

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

ha=(HashAlgorithm)new SHA1CryptoServiceProvider();
while((len=ins.Read(inbuf,0,MaxRSABlockSize))==MaxRSABlockSize) //читаем файл поблочно
{
    ha.TransformBlock(inbuf,0,MaxRSABlockSize,outbuf,0); //считаем хеш от блока данных
}
byte []hash=ha.TransformFinalBlock(inbuf,0,len); //досчитываем хеш от последнего куска

Можно считать хеш целиком внутри цикла, а TransformFinalBlock вызвать для массива нулевой длины.

Цифровая подпись

Ввиду неработоспособности метода SignHash классов RSACryptoServiceProvider и DSACryptoServiceProvider, пары классов RSAPKCS1SignatureFormatter/Deformatter и DSASignatureFormatter/Deformatter, похоже, являются единственным вариантом создания цифровой подписи. Обе пары классов унаследованы от классов AsymmetricSignatureFormatter/Deformatter, предоставляющих стандартный интерфейс создания и верификации цифровой подписи – методы CreateSignature и VerifySignature. Перед вычислением или проверкой цифровой подписи нужно обязательно установить алгоритм хеширования, который будет использоваться в процессе работы, с помощью вызова SetHashAlgorithm. RSAPKCS1SignatureFormatter понимает два алгоритма хеширования – MD5 и SHA1, а DSASignatureFormatter - только SHA1. Пример создания и проверки подписи уже был приведен в разделе, посвященном асимметричным шифрам в .NET, и повторять его здесь я не буду.

Заключение

И CryptoAPI, и .NET предоставляют богатый набор средств шифрования, позволяющих организовать собственную систему защиты данных без использования сторонних средств. В .NET, правда, эти средства пока плохо отлажены и документированы, но использовать их все равно можно. Основными препятствиями на пути использования средств CryptoAPI и .NET могут стать отсутствие на платформах ниже Windows 2000 поддержки “сильной” криптографии и требования, налагаемые Российским законодательством на средства шифрования, используемые в коммерческой деятельности. По Российскому законодательству эти средства обязательно должны быть сертифицированы ФАПСИ. Но ни один из алгоритмов, реализованных в CryptoAPI и .NET, не является сертифицированным. Правда, это не мешает разработчикам shareware-программ использовать алгоритмы типа RSA для защиты своих продуктов.

Ссылки

  1. Bruce Schneier, Applied Cryptography 2nd edition.
  2. MSDN, раздел Cryptography

Эта статья опубликована в журнале RSDN Magazine #1. Информацию о журнале можно найти здесь