Public Key Cryptography: осваиваем открытые ключи на практике

В этой статье я покажу, как реализовать основные операции по работе с 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 участвуют как минимум три стороны: Алиса, Боб и удостоверяющий центр (УЦ). У Алисы и Боба есть сертификаты с закрытым ключом, подписанные так называемым корневым сертификатом УЦ. У Алисы есть сертификат Боба с открытым ключом, а у Боба — сертификат Алисы с открытым ключом. Алиса и Боб доверяют УЦ и благодаря этому могут доверять друг другу.

Упрощенная структура 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.

Александр Ерыгин: Пишу код и наслаждаюсь жизнью.

Комментарии (3)

  • Небольшая опечатка - CNG (cryptography next geBeration), а разве нельзя просто подписывать хеш большого файла?

    • Можно, но иногда прикрепленная подпись большого файла — внешнее требование.

  • Можно, но иногда прикрепленная подпись большого файла - внешнее требование.

Похожие материалы