Итак. Пришла задача. Используя браузер предложить пользователю подписать PDF электронной подписью (далее ЭП). У пользователя должен быть токен, содержащий сертификат, открытый и закрытый ключ. Далее на сервере надо вставить подпись в PDF документ. После этого надо проверить подпись на валидность. В качестве back-end используем ASP.NET и соответственно C#.
Вся соль в том, что надо использовать подпись в формате CAdES-X Long Type 1, и российские ГОСТ Р 34.10-2001, ГОСТ Р 34.10-2012 и т.п. Кроме того подписей может быть более одной, то есть пользователи могут по очереди подписывать файл. При этом предыдущие подписи должны оставаться валидными.
В процессе решения задачу решили усложнить для нас, и уменьшить объем передаваемых данных на клиент. Передавать только hash документа, но не сам документ.
В исходниках буду опускать малозначимые для темы моменты, оставлю только то что касается криптографии. Код на JS приведу только для нормальных браузеров, JS-движки которых поддерживают Promise и function generator. Думаю кому нужно для IE напишут сами (мне пришлось «через не хочу»).
Что нужно:
Замечания:
Теперь будем считать что у нас на сервере есть готовый для подписывания PDF.
Добавляем на страницу скрипт от Крипто ПРО:
Дальше нам надо дождаться пока будет сформирован объект cadesplugin
Запрашиваем у сервера hash. Предварительно для этого нам ещё надо знать каким сертификатом, а значит и алгоритмом пользователь будет подписывать. Маленькая ремарка: все функции и «переменные» для работы с криптографией на стороне клиента я объединил в объект CryptographyObject.
Метод заполнения поля certificates объекта CryptographyObject:
Комментарий: пробуем открыть хранилище сертификатов. В этот момент система пользователя выдаст предупреждение, что сайт пытается что-то сделать с сертификатами, криптографией и прочей магической непонятной ерундой. Пользователю тут надо будет нажать кнопку «Да»
Далее получаем сертификаты, валидные по времени (не просроченные) и складываем их в массив certificates. Это надо сделать из-за асинхронной природы cadesplugin (для IE всё иначе ;) ).
Метод получения hash:
Комментарий: обратите внимание на cadesplugin.async_spawn, в нее передаётся функция-генератор, на которой последовательно вызывается next(), что приводит к переходу к yield.
Таким образом получается некий аналог async-await из C#. Всё выглядит синхронно, но работает асинхронно.
Теперь что происходит на сервере, когда у него запросили hash.
Во-первых необходимо установить nuget-пакет iTextSharp (на момент написания стать актуальная версия 5.5.13)
Во-вторых нужен CryptoPro.Sharpei, он идёт в нагрузку к Крипто ПРО .NET SDK
Теперь можно получать hash
На клиенте, получив hash от сервера подписываем его
Комментарий: полученную подпись отправляем на сервер (см. выше)
Ну и наконец вставляем подпись в документ на стороне сервера
Комментарий: SimpleExternalSignatureContainer — это простейший класс, реализующий интерфейс IExternalSignatureContainer
Собственно с подписанием PDF на этом всё. Проверка будет описана в продолжении статьи. Надеюсь, она будет…
Внёс исправления из комментария о получении Oid алгоритма подписи. Спасибо
Вся соль в том, что надо использовать подпись в формате CAdES-X Long Type 1, и российские ГОСТ Р 34.10-2001, ГОСТ Р 34.10-2012 и т.п. Кроме того подписей может быть более одной, то есть пользователи могут по очереди подписывать файл. При этом предыдущие подписи должны оставаться валидными.
В процессе решения задачу решили усложнить для нас, и уменьшить объем передаваемых данных на клиент. Передавать только hash документа, но не сам документ.
В исходниках буду опускать малозначимые для темы моменты, оставлю только то что касается криптографии. Код на JS приведу только для нормальных браузеров, JS-движки которых поддерживают Promise и function generator. Думаю кому нужно для IE напишут сами (мне пришлось «через не хочу»).
Что нужно:
- Пользователь должен получить пару ключей и сертификат.
- Пользователь должен установить plug-in от Крипто ПРО. Без этого средствами JS мы не сможем работать с криптопровайдером.
Замечания:
- Для тестов у меня был сертификат выданный тестовым ЦС Крипто ПРО и нормальный токен, полученный одним из наших сотрудников (на момент написания статьи ~1500р с годовой лицензией на Крипто ПРО и двумя сертификатами: но «новому» и «старому» ГОСТ)
- Говорят, plug-in умеет работать и с ViPNet, но я не проверял.
Теперь будем считать что у нас на сервере есть готовый для подписывания PDF.
Добавляем на страницу скрипт от Крипто ПРО:
<script src="/Scripts/cadesplugin_api.js" type="text/javascript"></script>
Дальше нам надо дождаться пока будет сформирован объект cadesplugin
window.cadespluginLoaded = false;
cadesplugin.then(function () {
window.cadespluginLoaded = true;
});
Запрашиваем у сервера hash. Предварительно для этого нам ещё надо знать каким сертификатом, а значит и алгоритмом пользователь будет подписывать. Маленькая ремарка: все функции и «переменные» для работы с криптографией на стороне клиента я объединил в объект CryptographyObject.
Метод заполнения поля certificates объекта CryptographyObject:
fillCertificates: function (failCallback) {
cadesplugin.async_spawn(function*() {
try {
let oStore = yield cadesplugin.CreateObjectAsync("CAPICOM.Store");
oStore.Open(cadesplugin.CAPICOM_CURRENT_USER_STORE,
cadesplugin.CAPICOM_MY_STORE,
cadesplugin.CAPICOM_STORE_OPEN_MAXIMUM_ALLOWED);
let certs = yield oStore.Certificates;
certs = yield certs.Find(cadesplugin.CAPICOM_CERTIFICATE_FIND_TIME_VALID);
let certsCount = yield certs.Count;
for (let i = 1; i <= certsCount; i++) {
let cert = yield certs.Item(i);
CryptographyObject.certificates.push(cert);
}
oStore.Close();
} catch (exc) {
failCallback(exc);
}
});
}
Комментарий: пробуем открыть хранилище сертификатов. В этот момент система пользователя выдаст предупреждение, что сайт пытается что-то сделать с сертификатами, криптографией и прочей магической непонятной ерундой. Пользователю тут надо будет нажать кнопку «Да»
Далее получаем сертификаты, валидные по времени (не просроченные) и складываем их в массив certificates. Это надо сделать из-за асинхронной природы cadesplugin (для IE всё иначе ;) ).
Метод получения hash:
getHash: function (certIndex, successCallback, failCallback, какие-то ещё параметры) {
try {
cadesplugin.async_spawn(function*() {
let cert = CryptographyObject.certificates[certIndex];
let certPublicKey = yield cert.PublicKey();
let certAlgorithm = yield certPublicKey.Algorithm;
let algorithmValue = yield certAlgorithm.Value;
let hashAlgorithm;
//определяем алгоритм подписания по данным из сертификата и получаем алгоритм хеширования
if (algorithmValue === "1.2.643.7.1.1.1.1") {
hashAlgorithm = "2012256";
} else if (algorithmValue === "1.2.643.7.1.1.1.2") {
hashAlgorithm = "2012512";
} else if (algorithmValue === "1.2.643.2.2.19") {
hashAlgorithm = "3411";
} else {
failCallback("Реализуемый алгоритм не подходит для подписания документа.");
return;
}
$.ajax({
url: "/Services/SignService.asmx/GetHash",
method: "POST",
contentType: "application/json; charset=utf-8 ",
dataType: "json",
data: JSON.stringify({
//какие-то данные для определения документа
//не забудем проверить на сервере имеет ли пользователь нужные права
hashAlgorithm: hashAlgorithm,
}),
complete: function (response) {
//получаем ответ от сервера, подписываем и отправляем подпись на сервер
if (response.status === 200) {
CryptographyObject.signHash(response.responseJSON,
function(data) {
$.ajax({
url: CryptographyObject.signServiceUrl,
method: "POST",
contentType: "application/json; charset=utf-8",
dataType: "json",
data: JSON.stringify({
Signature: data.Signature,
//какие-то данные для определения файла
//не забудем про серверную валидацию и авторизацию
}),
complete: function(response) {
if (response.status === 200)
successCallback();
else
failCallback();
}
});
},
certIndex);
} else {
failCallback();
}
}
});
});
} catch (exc) {
failCallback(exc);
}
}
Комментарий: обратите внимание на cadesplugin.async_spawn, в нее передаётся функция-генератор, на которой последовательно вызывается next(), что приводит к переходу к yield.
Таким образом получается некий аналог async-await из C#. Всё выглядит синхронно, но работает асинхронно.
Теперь что происходит на сервере, когда у него запросили hash.
Во-первых необходимо установить nuget-пакет iTextSharp (на момент написания стать актуальная версия 5.5.13)
Во-вторых нужен CryptoPro.Sharpei, он идёт в нагрузку к Крипто ПРО .NET SDK
Теперь можно получать hash
//определим hash-алгоритм
HashAlgorithm hashAlgorithm;
switch (hashAlgorithmName)
{
case "3411":
hashAlgorithm = new Gost3411CryptoServiceProvider();
break;
case "2012256":
hashAlgorithm = new Gost3411_2012_256CryptoServiceProvider();
break;
case "2012512":
hashAlgorithm = new Gost3411_2012_512CryptoServiceProvider();
break;
default:
GetLogger().AddError("Неизвестный алгоритм хеширования", $"hashAlgorithmName: {hashAlgorithmName}");
return HttpStatusCode.BadRequest;
}
//получим hash в строковом представлении, понятном cadesplugin
string hash;
using (hashAlgorithm)
//downloadResponse.RawBytes - просто массив байт исходного PDF файла
using (PdfReader reader = new PdfReader(downloadResponse.RawBytes))
{
//ищем уже существующие подписи
int existingSignaturesNumber = reader.AcroFields.GetSignatureNames().Count;
using (MemoryStream stream = new MemoryStream())
{
//добавляем пустой контейнер для новой подписи
using (PdfStamper st = PdfStamper.CreateSignature(reader, stream, '\0', null, true))
{
PdfSignatureAppearance appearance = st.SignatureAppearance;
//координаты надо менять в зависимости от существующего количества подписей, чтоб они не наложились друг на друга
appearance.SetVisibleSignature(new Rectangle(36, 100, 164, 150), reader.NumberOfPages,
//задаём имя поля, оно потом понадобиться для вставки подписи
$"{SignatureFieldNamePrefix}{existingSignaturesNumber + 1}");
//сообщаем, что подпись придёт извне
ExternalBlankSignatureContainer external =
new ExternalBlankSignatureContainer(PdfName.ADOBE_PPKLITE, PdfName.ADBE_PKCS7_DETACHED);
//третий параметр - сколько места в байтах мы выделяем под подпись
//я выделяю много, т.к. CAdES-X Long Type 1 содержит все сертификаты по цепочке до самого корневого центра
MakeSignature.SignExternalContainer(appearance, external, 65536);
//получаем поток, который содержит последовательность, которую мы хотим подписывать
using (Stream contentStream = appearance.GetRangeStream())
{
//вычисляем hash и переводим его в строку, понятную cadesplugin
hash = string.Join(string.Empty,
hashAlgorithm.ComputeHash(contentStream).Select(x => x.ToString("X2")));
}
}
//сохраняем stream куда хотим, он нам пригодиться, что бы вставить туда подпись
}
}
На клиенте, получив hash от сервера подписываем его
//certIndex - индекс в массиве сертификатов. На основании именно этого сертификата мы получали алгоритм и формировали hash на сервере
signHash: function (data, callback, certIndex, failCallback) {
try {
cadesplugin.async_spawn(function*() {
certIndex = certIndex | 0;
let oSigner = yield cadesplugin.CreateObjectAsync("CAdESCOM.CPSigner");
let cert = CryptographyObject.certificates[certIndex];
oSigner.propset_Certificate(cert);
oSigner.propset_Options(cadesplugin.CAPICOM_CERTIFICATE_INCLUDE_WHOLE_CHAIN);
//тут надо указать нормальный адрес TSP сервера. Это тестовый от Крипто ПРО
oSigner.propset_TSAAddress("https://www.cryptopro.ru/tsp/");
let hashObject = yield cadesplugin.CreateObjectAsync("CAdESCOM.HashedData");
let certPublicKey = yield cert.PublicKey();
let certAlgorithm = yield certPublicKey.Algorithm;
let algorithmValue = yield certAlgorithm.Value;
if (algorithmValue === "1.2.643.7.1.1.1.1") {
yield hashObject.propset_Algorithm(cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_256);
oSigner.propset_TSAAddress(CryptographyObject.tsaAddress2012);
} else if (algorithmValue === "1.2.643.7.1.1.1.2") {
yield hashObject.propset_Algorithm(cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_512);
oSigner.propset_TSAAddress(CryptographyObject.tsaAddress2012);
} else if (algorithmValue === "1.2.643.2.2.19") {
yield hashObject.propset_Algorithm(cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411);
oSigner.propset_TSAAddress(CryptographyObject.tsaAddress2001);
} else {
alert("Невозможно подписать документ этим сертификатом");
return;
}
//в объект описания hash вставляем уже готовый hash с сервера
yield hashObject.SetHashValue(data.Hash);
let oSignedData = yield cadesplugin.CreateObjectAsync("CAdESCOM.CadesSignedData");
oSignedData.propset_ContentEncoding(cadesplugin.CADESCOM_BASE64_TO_BINARY);
//результат подписания в base64
let signatureHex =
yield oSignedData.SignHash(hashObject, oSigner, cadesplugin.CADESCOM_CADES_X_LONG_TYPE_1);
data.Signature = signatureHex;
callback(data);
});
} catch (exc) {
failCallback(exc);
}
}
Комментарий: полученную подпись отправляем на сервер (см. выше)
Ну и наконец вставляем подпись в документ на стороне сервера
//всякие нужные проверки
//downloadResponse.RawBytes - ранее созданный PDF с пустым контейнером для подписи
using (PdfReader reader = new PdfReader(downloadResponse.RawBytes))
{
using (MemoryStream stream = new MemoryStream())
{
//requestData.Signature - собственно подпись от клиента
IExternalSignatureContainer external = new SimpleExternalSignatureContainer(Convert.FromBase64String(requestData.Signature));
//lastSignatureName - имя контейнера, которое мы определили при формировании hash
MakeSignature.SignDeferred(reader, lastSignatureName, stream, external);
//сохраняем подписанный файл
}
}
Комментарий: SimpleExternalSignatureContainer — это простейший класс, реализующий интерфейс IExternalSignatureContainer
/// <summary>
/// Простая реализация контейнера внешней подписи
/// </summary>
private class SimpleExternalSignatureContainer : IExternalSignatureContainer
{
private readonly byte[] _signedBytes;
public SimpleExternalSignatureContainer(byte[] signedBytes)
{
_signedBytes = signedBytes;
}
public byte[] Sign(Stream data)
{
return _signedBytes;
}
public void ModifySigningDictionary(PdfDictionary signDic)
{
}
}
Собственно с подписанием PDF на этом всё. Проверка будет описана в продолжении статьи. Надеюсь, она будет…
Источники вдохновения
www.cryptopro.ru/sites/default/files/products/cades/demopage/cades_xlong_sample.html
cpdn.cryptopro.ru/content/cades/plugin-activation.html
www.cryptopro.ru/forum2/default.aspx?g=posts&t=11119
www.cryptopro.ru/forum2/default.aspx?g=posts&t=3691&p=21
cpdn.cryptopro.ru/default.asp?url=content/cades/plugin-samples-raw-signature.html
cpdn.cryptopro.ru/default.asp?url=content/cades/plugin.html
itextsupport.com/apidocs/itext5/5.5.9/com/itextpdf/text/pdf/PdfStamper.html#createSignature-com.itextpdf.text.pdf.PdfReader-java.io.OutputStream-char-java.io.File-boolean-
cpdn.cryptopro.ru/content/cades/plugin-activation.html
www.cryptopro.ru/forum2/default.aspx?g=posts&t=11119
www.cryptopro.ru/forum2/default.aspx?g=posts&t=3691&p=21
cpdn.cryptopro.ru/default.asp?url=content/cades/plugin-samples-raw-signature.html
cpdn.cryptopro.ru/default.asp?url=content/cades/plugin.html
itextsupport.com/apidocs/itext5/5.5.9/com/itextpdf/text/pdf/PdfStamper.html#createSignature-com.itextpdf.text.pdf.PdfReader-java.io.OutputStream-char-java.io.File-boolean-
Внёс исправления из комментария о получении Oid алгоритма подписи. Спасибо