Подписание PDF на JS и вставка подписи на C#, используя Крипто ПРО

Итак. Пришла задача. Используя браузер предложить пользователю подписать 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 напишут сами (мне пришлось «через не хочу»).

Что нужно:

  1. Пользователь должен получить пару ключей и сертификат.
  2. Пользователь должен установить plug-in от Крипто ПРО. Без этого средствами JS мы не сможем работать с криптопровайдером.

Замечания:

  1. Для тестов у меня был сертификат выданный тестовым ЦС Крипто ПРО и нормальный токен, полученный одним из наших сотрудников (на момент написания статьи ~1500р с годовой лицензией на Крипто ПРО и двумя сертификатами: но «новому» и «старому» ГОСТ)
  2. Говорят, 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 на этом всё. Проверка будет описана в продолжении статьи. Надеюсь, она будет…



Внёс исправления из комментария о получении Oid алгоритма подписи. Спасибо
Поделиться публикацией
Комментарии 35
    0

    Подскажите, а как-то видно, что документ pdf подписан электронной подписью (при распечатке)? Либо наличие подписи можно проверить, только имея программу и этот файл pdf?

      0
      Есть немного кода, который я из статьи убрал, там генерируется видимая подпись. У нас она выглядит как прямоугольная печать. Различные PDF-просмотровщики позволяют так же проверить подпись. Но с ГОСТ всё сложно, нужны плагины
        0
        Наличие «видимой подписи» как раз ничего не доказывает юридически. Это картинка, которую может кто угодно нафотошопить. Доказательством подписания документа является прохождение проверки ЭП в нем сертифицированной (кажется ФСБ) программой.

        Можно проверить файл в Acrobat Reader ВС (установив и настроив плагин от КриптоПРО). Также есть несколько сайтов на которых можно бесплатно проверить ЭП в документе, так что независимая проверка обеспечена
          0

          Да, насчет подтверждения подписи понятно. Просто "бухгалтерам" иногда требуется предоставить копию документа третьему лицу. Допустим — это какой-то бухгалтерский документ в формате pdf, подписанный электронной подписью. Как в этом случае должно всё работать? Просто отправлять файл и третье лицо пускай само все проверяет — есть подпись, или нет подписи? Как-то это кажется не совсем удобно.

            0

            А как с обычной подпись на бумаге? Вы отдаете документ и третье лицо само разбирает есть ли, чья подпись. В случае ЭП ему (третьему лицу) много проще — они может проверить кто именно подписал

        +1
        Вы в курсе что это все абсолютно незаконно? Нельзя подписывать ЭПЦ пользователя то что пользователь не видит.
        И даже подменять файлики нельзя. То есть показать одно а подписать другое нельзя.
          0
          Можно подробнее насчёт показать одно, а подписать другое. Где вы такое усмотрели?
          И где тут идёт подмена файлов?
          Берём PDF добавляем в него контейнер, считаем хеш и отправляем на подпись пользователю, пользователь присылает подпись, мы ее вставляем в этот же самый PDF.
          Где подмена?
            +1
            Есть закон. Ссылку не дам, но формулируется так: «Пользователь может подписать только тот документ который он видит.»

            Есть несколько типовых сценариев подписи pdf:
            1. С сервера прилетает файл. Показывается на клиенте. По кнопке Подписать считаем хеш и подписываем.
            2. С сервера прилетаем файл. Показываем его. По кнопке Подписать запрашиваем хеш с сервера и подписываем.
            3. С сервера прилетает некое изображение документа. По кнопке Подписать запрашиваем хеш и подписываем.
            4. По кнопке Подписать запрашиваем хеш и подписываем.

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

            У вас статья о четвертом сценарии.
            В процессе решения задачу решили усложнить для нас, и уменьшить объем передаваемых данных на клиент. Передавать только hash документа, но не сам документ.

            Не надо так делать.
              0
              До и после подписания юзер может выгрузить файл и посмотреть его
              Мы же не в «тёмную» ему хеш отправляем
              Ну и, конечно, ссылочку на правовой документ было бы неплохо увидеть
                +1
                Если пользователь может выгрузить файл до подписания «as is», значит он технически может рассчитать на своей машине хеш и убедиться при помощи «независимых решений», что он подписал строго тот файл, который скачал.
                Значит ли это, что если есть решение по варианту 4, но со ссылкой, то это законно?

                Если это не законно, то как многие организации (в том числе банки) показывают, например, соглашение о персональных данных или лицензионное соглашение в виде ссылки? Ведь тогда тоже пользователь соглашается с тем, что может не видеть в момент соглашения?
                  0

                  Да и вообще почти все вне общения с государством используют не ГОСТ подпись. Много чего работает, пока юристы к этому прикапываться не начнут.


                  У тс гост подпись. Значит государство очень рядом. И надо соответствовать.

                  +1

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


                  Ссылочку как уже и писал не дам. Я далек от юристов. А это их вопрос. Я только вывод знаю.

                    0

                    Хеш на то и хеш, что по нему можно однозначно определить, соответствует ли он файлу F или нет. Мы даём пользователю доступ к файлу F и отдельно загружаем с сервера хеш X(F). В чем отличие от того, что мы загружаем файл и на стороне клиента вычисляем X(F)? Если подозревать, что сервер сделал хеш другого файла, то это легко может быть проверено… В любом случае А) проведенное автором решение не теряет своей ценности, и с доработками интересно и для случая расчета хеша на стороне клиента: Б) вывод ваших юристов не понял совсем… Если они говорят, что нельзя дать документ на подпись, не представляя к нему доступ, — понятно, без вопросов. Но если речь только о том в каком режиме доступ к файлу организовать — совсем не понятно. Так можно говорить, что пусть файл и скачали мне на сторону клиента для подписи, но я все равно его не выгружал и не открывал документ — значит подписал вслепую и документ не действителен.
                    Есть файл. Есть хеш и стороны могут доказать, что это хеш файла, есть подпись и стороны могут доказать, что подпись хеша. Файл был доступен контрагенту до подписания. Где слабое звено?

                      0
                      Я не про подмену хеша говорю. Ее организовать можно в любом случае. Для клиента нет адекватного способа проверить до подписания что именно он подписывает.
                      Клиент должен доверять софту. Софт должен заботиться о безопасности. Это все по умолчанию.

                      Я именно про тонкости организации самого процесса. Про подробности я говорить не готов, не в курсе. Допускаю что юристы просто перестраховались. Маловероятно, но возможно.

                      Решение само по себе вполне типичное. Проблема только во внедренной ГОСТовой подписи. Весь типовой софт будет ругаться на эти подписи. Но с этим ничего не сделать.
                        0
                        Пообщались с нашим юристом, он сказал что всё хорошо.
                        Насколько понимаю пока нет достаточной правоприменительной практики по ЭП.
                          0
                          Я вообще не понимаю что там почему и как с юридической стороны. Есть сомнения спрашиваю. Делаю как скажут. Если этот вопрос еще раз возникнет уточню еще раз обязательно.

                          Меня вообще бесит текущая (года 2 назад точно) ситуация. А вам для чего ЭПЦ нужна? Вот для такого портала такая, а для такого другая.
                            0
                            У нас двусторонне подписание между госом и физиком или юриком
                0

                хэш подменили

              0
              У нас есть готовый продукт для решения этой задачи — КриптоПро DSS Lite.
                +1
                Да, конечно. Только вопрос цены
                0
                Как все просто выглядит. Я реализовывал подпись документов docx на фронтенде, написанном на питоне — вот там была жесть. Хеш посчитай, xml нормализуй, хеш отзеркалируй и т.п. И самое гнусное — нигде нет нормальной документации для этого действа.
                И я так и не понял, как в браузере не дать пользователю выбрать не ГОСТ-овский токен
                  0
                  простой фильтрацией certAlgorithmFriendlyName.match ГОСТ или GOST насколько я понял имя зависит от языкового пакета Windows или что там установлено на клиенте
                    0
                    Выглядит просто, но собрать это всё вместе чтоб работало… команда работала, искала материалы, по крупицам собирала информацию в доступных источниках.
                    Могу сказать, что это было не очень просто, но в итоге заработало как надо.
                    0
                    подписывать файл присоединенной подписью мало смысла, пк на котром откроют пдф будет говорить об ошибке сертификата, так как почти ни у кого нет крипто про и рлагина. Отсоединенную сразу ругать не будет и можно в сопровожиловке отправить за проверкой на госуслуги
                      0
                      Так тут речь о встреченной подписи:
                      вставляем подпись в документ
                        0
                        да, подпись вставляем прямо в PDF
                        0
                        хм… у нас предполагается двусторонее подписание. Слишком сложно все эти подписи за собой таскать, да и как простому пользователю объяснить, что вот этот файлик и есть твоя подпись. А вот когда в PDF-нике есть фиолетовая печать с ФИО это всем понятно
                          0

                          Отсоединную подпись могут и потерять. И что это такое "зачем мне это файл" многие не понимают. Во всяком случае ФНС использует встроенные

                          0
                          При этом предыдущие подписи должны оставаться валидными.

                          А что бывает по-другому? Странно.

                            0

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

                              0
                              Встроенные позволяют подписывать уже подписанный документ. С отсоединенными такое провернуть сложно. Так чтобы первая подпись затеряться где-нибудь потом не смогла.
                                0
                                О преимуществах встроенной подписи не спорю.
                                Коллега спрашивал «бывает ли чтобы 2 подпись делала первую невалидной» — с кривыми руками при работе со встроенными подписями — легко. Это не отменяет принципиальных плюсов встроенных
                              0
                              А чем отличается такая подпись от подписи c простым OpenSSL?
                                0
                                для информации:
                                для каждого алгоритма определен OID
                                cpdn.cryptopro.ru/content/csp40/html/group___pro_c_s_p_ex_DP8.html

                                algo = yield pubKey.Algorithm;
                                algvalue=yield algo.Value;
                                if (algvalue===«1.2.643.7.1.1.1.1»)
                                hashAlg=cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_256;

                                еще могут возникнуть проблемы если на клиенте старый плагин или браузер, поэтому желательно проверить
                                var canPromise = !!window.Promise;
                                var canAsync = !!cadesplugin.CreateObjectAsync;

                                описано здесь cpdn.cryptopro.ru/content/cades/plugin-activation.html
                                  0
                                  За Oid большое спасибо, поправим. Это действительно более правильный способ. Сделал через строку, т.к. не знал как получить Oid на клиенте.

                                  Про Promise в начале статьи писал, что для IE пришлось реализовывать отдельный скрипт и делать так
                                  $(function() {
                                      try {
                                          eval("(function *(){})");
                                          window.generatorSupported = true;
                                      } catch (err) {
                                          window.generatorSupported = false;
                                      }
                                  
                                      let scriptUrl;
                                      if (generatorSupported) {
                                          scriptUrl = "/Scripts/cryptography.js?v=2.24.0";
                                      } else {
                                          scriptUrl = "/Scripts/cryptography_ie.js?v=2.24.0";
                                      }
                                      $.ajax({
                                          dataType: "script",
                                          cache: true,
                                          url: scriptUrl,
                                          success: window.fillCryptographyObjectParams,
                                      });
                                  });
                                  


                                  И соответственно в cryptography.js работа в асинхронном режиме, а в cryptography_ie в синхронном

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое