Содержание статьи
В этой статье я покажу, как реализовать основные операции по работе с PKI. Речь идет о подписи, проверке подписи, шифровании и расшифровании в контексте PKI. Теоретически данный код может использоваться с любым CSP (Cryptography Service Provider), поддерживающим интерфейс MS Crypto API. Я пользуюсь бесплатным отечественным CSP. Почему отечественным? Дело в том, что в .NET нет поддержки алгоритмов ГОСТ. А если мы работаем с реальными проектами и, следовательно, вынуждены соответствовать требованиям регуляторов в России, то без гостовских алгоритмов нам никак. Но если установить отечественный криптопровайдер, поддерживающий MS Crypto API, то все будет тип-топ, потому что .NET при работе с CMS дергает именно MS Crypto API.
Картина мира
Перед погружением в код давай разберем немного терминологии. PKI — инфраструктура открытых ключей. Как несложно догадаться, PKI основана на асимметричном шифровании. В симметричных шифрах для шифрования и расшифрования используется один ключ. В асимметричных для шифрования используется один ключ, а для расшифрования — другой. Вместе они образуют ключевую пару.
Информация, необходимая для работы PKI, содержится в сертификате X.509. В PKI участвуют как минимум три стороны: Алиса, Боб и удостоверяющий центр (УЦ). У Алисы и Боба есть сертификаты с закрытым ключом, подписанные так называемым корневым сертификатом УЦ. У Алисы есть сертификат Боба с открытым ключом, а у Боба — сертификат Алисы с открытым ключом. Алиса и Боб доверяют УЦ и благодаря этому могут доверять друг другу.
Сертификаты X.509
Так повелось, что основным «активом» в PKI является сертификат X.509. Сертификат — это что-то вроде паспорта, он содержит информацию, позволяющую идентифицировать субъект, которому выдан сертификат (поле Subject), указывает, кем он был выпущен (поле Issuer), серийный номер сертификата и многое другое. В Windows управлять сертификатами можно с помощью оснастки «Сертификаты» (run->certmgr.msc
).
Сертификаты хранятся в хранилищах («Личное», «Доверенные центры сертификации», «Доверенные лица»...).
При получении сертификата важно установить его в правильное хранилище. Так, сертификаты, которые ты хочешь использовать для электронной подписи, должны быть установлены в хранилище «Личное», а сертификаты получателей, которым нужно будет отправлять зашифрованные сообщения, — в хранилище «Доверенные лица». Сертификаты удостоверяющих центров (УЦ) должны быть установлены в хранилище «Доверенные корневые центры сертификации». При установке сертификата система предлагает два варианта: выбрать хранилище автоматически либо указать вручную. Рекомендую использовать второй вариант, так как автоматика иногда устанавливает сертификат не в то хранилище. Сертификат, которым мы хотим подписывать сообщения, должен иметь закрытый ключ. О наличии закрытого ключа можно узнать, посмотрев на свойства сертификата, где русским по белому будет написано: «есть закрытый ключ для этого сертификата».
Самое интересное о сертификате мы можем узнать на вкладке «Состав».
Обрати внимание на поля «Алгоритм подписи», «Алгоритм хеширования подписи» и «Открытый ключ». Если хочешь использовать сертификат для осуществления транзакций в России, во всех этих полях ты должен видеть слово «ГОСТ». Также следует обратить внимание на значение поля «Использование ключа» и поля «Действителен с» и «Действителен по»: первое позволит понять, возможно ли использование сертификата для выполнения нужной нам операции (шифрование, подпись), а второе и третье — возможно ли использовать данный сертификат в указанный момент времени. В дополнение к этому следует убедиться, что сертификат действителен. В этом нам поможет вкладка «Путь сертификации». Если с сертификатом все хорошо, мы увидим надпись: «Этот сертификат действителен».
WARNING
Приведенный ниже код предназначен исключительно для ознакомления с PKI. Не следует без оглядки использовать его в реальной работе.
Цифровая подпись
Представь, дорогой читатель, что ты занимаешься некой очень ответственной работой. И результаты своей работы отправляешь в виде отчетов, от которых в конечном итоге зависят чьи-то конкретные судьбы и жизни. Получатели твоих отчетов принимают на их основе очень важные решения, и, если ты напортачишь, вполне можешь получить срок. Так вот, в таких ответственных организациях без электронной подписи никуда. Она позволяет тебе подписать тот самый суперважный секретный отчет своим сертификатом с закрытым ключом. Закрытый ключ, в идеале, может храниться на токене — специальном съемном устройстве, похожем на флешку, которое ты в редкие моменты достаешь из сейфа. Подпись гарантирует, что твой отчет отправлен именно тобой, а не уборщицей или сторожем. С другой стороны, ты не сможешь отказаться от авторства (это называется «неотрекаемость») и, если накосячишь в своем суперважном документе, на сторожа свалить вину не получится.
Электронная подпись применяется не только в спецслужбах и органах, но и в бизнесе. Например, для перевода пенсионных накоплений в НПФ: мы генерируем запрос на сертификат, отправляем его в удостоверяющий центр (УЦ). УЦ выпускает сертификат, мы подписываем сертификатом заявление на перевод пенсионных накоплений, отправляем — и вуаля. Подпись также позволяет осуществлять контроль целостности подписываемых данных. Если данные будут изменены, подпись не пройдет проверку.
Для программирования подписи необходимо ознакомиться с несколькими классами .NET Framework:
- X509Certificate2 — представляет собой сертификат X.509. Имя класса, оканчивающееся на 2, говорит о том, что класс является усовершенствованным аналогом класса X509Certificate.
- X509Chain.aspx) — позволяет строить и проверять цепочку сертификатов. Необходима для того, чтобы убедиться в действительности сертификата.
- SignedCms.aspx) — позволяет подписывать и проверять сообщения PKCS#7.
Перед тем как заюзать наш сертификат, необходимо его проверить. Процедура включает в себя проверку цепочки сертификации, проверку срока действия и проверку, не отозван ли сертификат. Если мы подпишем файл недействительным сертификатом, подпись будет недействительной.
X509Chain certificateChain = new X509Chain {
ChainPolicy = {
RevocationMode = X509RevocationMode.Online,
VerificationFlags = X509VerificationFlags.IgnoreNotTimeValid,
RevocationFlag = X509RevocationFlag.ExcludeRoot
}
};
bool chainOk = certificateChain.Build(certificate);
bool certNotExpired = (certificate.NotAfter >= DateTime.Now) && (certificate.NotBefore <= DateTime.Now);
Мы проверили сертификат и убедились, что он в порядке. Переходим непосредственно к подписыванию данных. Подпись бывает двух видов: прикрепленная и открепленная.
Результатом прикрепленной подписи будет CMS (Cryptography Message Syntax) — сообщение, содержащее как подписываемые данные, так и саму подпись. Открепленная подпись содержит только саму подпись. Рекомендую использовать именно открепленную подпись, потому что с ней намного меньше мороки. В нее проще поставить метку времени, она меньше весит, так как не содержит подписываемые данные. Подписываемые данные легко открыть, посмотреть. В случае прикрепленной подписи для того, чтобы просмотреть подписанные данные, CMS-сообщение необходимо сначала декодировать. В общем, прикрепленной подписи я рекомендую избегать всеми силами. Если потребуется передавать подпись и контент вместе, рассмотри вариант архивирования (вместо использования прикрепленной подписи используй открепленную, просто заархивируй подписываемый файл и открепленную подпись). Посмотрим на код подписи (С#):
public byte[] SignAttached(X509Certificate2 certificate, byte[] dataToSign) {
ContentInfo contentInfo = new ContentInfo(dataToSign);
SignedCms cms = new SignedCms(contentInfo, false);
CmsSigner signer = new CmsSigner(certificate);
cms.ComputeSignature(signer, false);
return cms.Encode();
}
public byte[] SignDetached(X509Certificate2 certificate, byte[] dataToSign) {
ContentInfo contentInfo = new ContentInfo(dataToSign);
SignedCms cms = new SignedCms(contentInfo, true);
CmsSigner signer = new CmsSigner(certificate);
cms.ComputeSignature(signer, false);
return cms.Encode();
}
Но, как обычно это бывает у Microsoft, стоит сделать маленький шаг в сторону, и розовый волшебный мир рушится
Глядя на примеры кода, можно подумать, что работа с подписью в .NET реализована достаточно хорошо. Но рассмотрим, например, случай, в котором необходимо осуществить подпись большого файла, размером 600 MiB. Внимательные читатели обратили внимание на сигнатуру метода подписи — он принимает на вход массив байтов. При попытке загрузить в массив байтов 600 MiB мы получим OutOfMemoryException.
Что же делать, спросишь ты? Обращаться к основам — отвечу я! Очевидно, раз нельзя загрузить в память 600 MiB, то необходимо файл грузить и обрабатывать по кусочкам. .NET-обертки над CMS так не умеют. На помощь нам приходит MS Crypto API. MS Crypto API содержит два набора функций для работы с CMS: Simplified Message Fuctions и Low Level Message Functions. Для работы с большими файлами нам нужны Low Level. Полную реализацию на C# можно посмотреть тут. Я же предпочитаю работать с криптографией на языке C++. Кода в результате писать приходится меньше, а работает он быстрее. Рассмотрим порядок действий для реализации подписи в поточном режиме:
- Получаем
PCCERT\_CONTEXT
; - Заполняем структуры
CMSG\_STREAM\_INFO
,CRYPT\_ALGORITHM\_IDENTIFIER
,CMSG\_SIGNER\_ENCODE\_INFO
,CMSG\_SIGNED\_ENCODE\_INFO
; - Получаем хендл сообщения с помощью функции
CryptMsgOpenToEncode
. Для открепленной подписи необходимо передать соответствующий флагCMSG\_DETACHED\_FLAG
; - В цикле вызываем функцию
CryptMsgUpdate
и «скармливаем» ей по кусочкам файл, который необходимо подписать.
На C++ будет что-то вроде:
ISigner signer = null;
// Заполняем структуры
...
// Открываем сообщение для кодирования
HCRYPTMSG hMsg = CryptMsgOpenToEncode (
(X509_ASN_ENCODING | PKCS_7_ASN_ENCODING), // Message encoding type
dwFlags, // Flags
CMSG_SIGNED, // Message type
&SignedMsgEncodeInfo, // Pointer to structure
NULL, // Inner content object ID
&stStreamInfo // Stream information (not used)
);
...
// Обрабатываем файл для подписи по кусочкам
while ( ( bytesRead = inputStream->Read(buf, blockSize, 0, blockSize ) ) > 0 ) {
processedDataLen += bytesRead;
BOOL lastcall = (processedDataLen == streamLength);
BOOL successful = CryptMsgUpdate(hMsg, (const BYTE*)buf, bytesRead, lastcall);
}
// Закрываем хендл
CryptMsgClose(hMsg);
return S_OK;
Вызов кода на C++ из C# будет выглядеть примерно так:
ISigner signer = null;
PkiFactory.CreateSigner(out signer);
X509Store store = new X509Store("My");
store.Open(OpenFlags.ReadOnly);
var cert = GetCertificates(store);
string file = @"d:\tmp\masyanya.bin";
using (var inputStream = File.OpenRead(file))
using (var outputStream = File.Create(file + ".sig")) {
var reader = new StreamReader(inputStream);
var writer = new StreamWriter(outputStream);
int result = signer.Sign(reader, writer, cert, 1);
Debug.Assert(result == 0, "Подпись не прошла.");
}
Внимательный читатель удивится — что это за IStreamReader* inputStream
, IStreamWriter* outputStream
, ICertificate* signCertificate
? Ответ следует из названия переменных, но есть одна тонкость, которая для многих окажется сюрпризом. IStreamReader, IStreamWriter, ICertificate — это интерфейсы, реализованные на C#, и это не COM-объекты. При этом мы спокойно можем вызывать их методы в нативном C++. Как сделать такую красоту — тема отдельной статьи. В результате успешного выполнения операции мы получим криптографическое сообщение. Для кодирования сертификатов X.509 и криптографических сообщений используется Abstract Syntax Notation One, или, по-простому, ASN 1. Для просмотра файлов, закодированных в ASN 1, можно воспользоваться бесплатным ASN.1 Editor.
INFO
Среди .NET-девелоперов бытует мнение, что программировать на C++ сложнее и дольше. Уверяю тебя, в случае с криптографией ситуация противоположна. Гораздо быстрее написать код на C++ и вызвать его из .NET.
Проверка подписи и декодирование
А теперь, дорогой читатель, представь, что ты большой начальник и должен принять важное стратегическое решение на основе отчета, который тебе прислал сотрудник по электронной почте. Для твоего удобства отчет был подписан открепленной подписью. Открыв почту и скачав отчет, ты, как опытный, знающий жизнь человек, не спешишь принимать на веру содержимое отчета и проверяешь подпись. После проверки выясняешь, что подпись неверна — не сошлась контрольная сумма. В результате оповещаешь службу безопасности, которая проводит расследование и выясняет, что хитрые конкуренты взломали почтовый сервер и отправили тебе фальшивый документ. Тебя наградили за бдительность, конкурентов посадили, а компания наконец-то получила оригинальный отчет с проверенной электронной подписью.
Если пользователь прислал тебе отчет в виде прикрепленной подписи, тебе для чтения придется его декодировать:
public bool VerifyAttached(byte[] dataToVerify) {
try {
var cms = new SignedCms();
cms.Decode(dataToVerify);
foreach (var signer in cms.SignerInfos) {
signer.CheckSignature(true);
}
return true;
}
catch (CryptographicException) {
return false;
}
}
public byte[] Decode(byte[] signedCms) {
var cms = new SignedCms();
cms.Decode(signedCms);
return cms.ContentInfo.Content;
}
Нетрудно догадаться, что и тут разработчики .NET Framework подложили нам свинью. Не можем мы проверить подпись большого файла! По той же самой причине — OutOfMemoryException. Но и эту проблему несложно решить, обратившись к магии MS Crypto API. Так как код поточной проверки подписи достаточно длинный, остановлюсь на основных моментах:
// Заполняем структуры
...
// Открываем сообщение для декодирования
HCRYPTMSG msg = CryptMsgOpenToDecode(...);
// Декодируем сообщение по кусочкам
while ((bytesRead = inputStream->Read(&buf.at(0), blockSize, 0, blockSize)) > 0) {
totalBytesRead += bytesRead;
CryptMsgUpdate(msg, &buf.at(0), bytesRead, totalBytesRead == fileSize);
}
// Получаем информацию о подписанте
PCCERT_CONTEXT pSignerCertContext = CertGetSubjectCertificateFromStore(
hStore,
X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
(PCERT_INFO)((void*)(&signerCertInfo.at(0))));
// Проверяем подпись
BOOL ok = CryptMsgControl(msg, 0, CMSG_CTRL_VERIFY_SIGNATURE, pSignerCertContext->pCertInfo));
А так будет выглядеть код проверки подписи при вызове из C#:
ISignatureVerifier verifier = null;
PkiFactory.CreateSignatureVerifier(out verifier);
using (var inputStream = File.OpenRead(file + ".sig"))
using (var contentStream = File.OpenRead(file)) {
var reader = new StreamReader(inputStream);
var contentReader = new StreamReader(contentStream);
var error = verifier.VerifyDetachedSign(contentReader, reader);
Debug.Assert(error == 0, "Проверка подписи не прошла");
}
Шифрование
Зачем нужно шифрование, все уже знают. PKI нам дает полезную плюшку: мы можем зашифровать один документ так, что расшифровать его смогут несколько получателей. Это очень удобно. Для этого нам нужно иметь сертификаты получателей.
public byte[] Encrypt(byte[] dataToEncrypt, params X509Certificate2[] recepients) {
var contentInfo = new ContentInfo(dataToEncrypt);
var recipientsCertificates = new X509Certificate2Collection(recepients);
var recipients = new CmsRecipientCollection(SubjectIdentifierType.IssuerAndSerialNumber, recipientsCertificates);
var cms = new EnvelopedCms(contentInfo);
cms.Encrypt(recipients);
return cms.Encode();
}
Расшифрование
При расшифровании необходимо, чтобы сертификат, указанный при шифровании в коллекции получателей, был установлен в хранилище сертификатов. Так как сообщение может быть зашифровано и адресовано нескольким получателям, для расшифрования нам необходимо найти того получателя, сертификат которого установлен в нашем хранилище сертификатов.
public byte[] Decrypt(byte[] encryptedData) {
var envelopedCms = new EnvelopedCms();
envelopedCms.Decode(encryptedData);
X509Store store = new X509Store("My");
store.Open(OpenFlags.ReadOnly);
RecipientInfo recipientInfo = envelopedCms.RecipientInfos.Cast<RecipientInfo>()
.FirstOrDefault(x => FindCertificate((X509IssuerSerial)x.RecipientIdentifier.Value) != null);
envelopedCms.Decrypt(recipientInfo);
return envelopedCms.ContentInfo.Content;
}
private X509Certificate2 FindCertificate(X509IssuerSerial issuerSerial) {
var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadOnly | OpenFlags.OpenExistingOnly);
return store.Certificates
.Find(X509FindType.FindByIssuerDistinguishedName, issuerSerial.IssuerName, false)
.Find(X509FindType.FindBySerialNumber, issuerSerial.SerialNumber, false).Cast<X509Certificate2>()
.FirstOrDefault();
}
Заключение
В статье не удалось охватить все аспекты PKI, так как их очень много. Тем не менее закодить основные операции ты теперь сможешь без проблем. Разработчикам, которые умеют писать на C#, рекомендую освоить C++ хотя бы на базовом уровне. Это очень пригодится, когда придется работать с нативными функциями. А до многих возможностей ОС по-другому и не добраться, так как .NET реализует весьма ограниченный набор возможностей. Например, .NET не имеет полной поддержки MS Crypto API и CNG (cryptography next geberation), поэтому тебе придется писать тонны P/invoke-кода на С# либо значительно меньше на C++.
Как видишь, работа с PKI достаточно сложная и требует серьезной подготовки, выдержки и терпения. Перед тем как бросаться реализовывать классные фичи, крайне важно понимать основные концепции PKI.
За кадром остались поточное шифрование и расшифрование, подпись несколькими сертификатами, генерация запросов на сертификат и многое другое, но основу я тебе показал. Код примеров с тестами можно скачать на GitHub.