Итак. Пришла задача. Используя браузер предложить пользователю подписать 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 certAlgorithmFriendlyName = yield certAlgorithm.FriendlyName;
                let hashAlgorithm;
                //определяем алгоритм подписания по данным из сертификата и получаем алгоритм хеширования
                if (certAlgorithmFriendlyName.match(/2012 512/i))
                    hashAlgorithm = "2012512";
                else if (certAlgorithmFriendlyName.match(/2012 256/i))
                    hashAlgorithm = "2012256";
                else if (certAlgorithmFriendlyName.match(/2001/i))
                    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 certAlgorithmFriendlyName = yield certAlgorithm.FriendlyName;

                if (certAlgorithmFriendlyName.match(/2012 512/i)) {
                    yield hashObject.propset_Algorithm(cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_512);
                } else if (certAlgorithmFriendlyName.match(/2012 256/i)) {
                    yield hashObject.propset_Algorithm(cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411_2012_256);
                } else if (certAlgorithmFriendlyName.match(/2001/i)) {
                    yield hashObject.propset_Algorithm(cadesplugin.CADESCOM_HASH_ALGORITHM_CP_GOST_3411);
                } 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 на этом всё. Проверка будет описана в продолжении статьи. Надеюсь, она будет…

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


  1. TiesP
    11.10.2018 15:57

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


    1. Sonkkorh Автор
      11.10.2018 17:21

      Есть немного кода, который я из статьи убрал, там генерируется видимая подпись. У нас она выглядит как прямоугольная печать. Различные PDF-просмотровщики позволяют так же проверить подпись. Но с ГОСТ всё сложно, нужны плагины


    1. vanderPon
      11.10.2018 19:38

      Наличие «видимой подписи» как раз ничего не доказывает юридически. Это картинка, которую может кто угодно нафотошопить. Доказательством подписания документа является прохождение проверки ЭП в нем сертифицированной (кажется ФСБ) программой.

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


      1. TiesP
        11.10.2018 21:04

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


        1. vanderPon
          11.10.2018 22:02

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


  1. BugM
    11.10.2018 16:07
    +1

    Вы в курсе что это все абсолютно незаконно? Нельзя подписывать ЭПЦ пользователя то что пользователь не видит.
    И даже подменять файлики нельзя. То есть показать одно а подписать другое нельзя.


    1. Sonkkorh Автор
      11.10.2018 17:24

      Можно подробнее насчёт показать одно, а подписать другое. Где вы такое усмотрели?
      И где тут идёт подмена файлов?
      Берём PDF добавляем в него контейнер, считаем хеш и отправляем на подпись пользователю, пользователь присылает подпись, мы ее вставляем в этот же самый PDF.
      Где подмена?


      1. BugM
        11.10.2018 17:57

        Есть закон. Ссылку не дам, но формулируется так: «Пользователь может подписать только тот документ который он видит.»

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

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

        У вас статья о четвертом сценарии.

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

        Не надо так делать.


        1. Sonkkorh Автор
          11.10.2018 18:37

          До и после подписания юзер может выгрузить файл и посмотреть его
          Мы же не в «тёмную» ему хеш отправляем
          Ну и, конечно, ссылочку на правовой документ было бы неплохо увидеть


          1. vanderPon
            11.10.2018 19:34

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

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


            1. BugM
              11.10.2018 21:21

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


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


          1. BugM
            11.10.2018 21:19

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


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


            1. vanderPon
              12.10.2018 01:14

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


              1. BugM
                12.10.2018 13:10

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

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

                Решение само по себе вполне типичное. Проблема только во внедренной ГОСТовой подписи. Весь типовой софт будет ругаться на эти подписи. Но с этим ничего не сделать.


      1. saipr
        12.10.2018 08:35

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


  1. maxdm
    11.10.2018 17:06

    У нас есть готовый продукт для решения этой задачи — КриптоПро DSS Lite.


    1. Sonkkorh Автор
      11.10.2018 17:18
      +1

      Да, конечно. Только вопрос цены


  1. swamp_scout
    11.10.2018 19:04

    Как все просто выглядит. Я реализовывал подпись документов docx на фронтенде, написанном на питоне — вот там была жесть. Хеш посчитай, xml нормализуй, хеш отзеркалируй и т.п. И самое гнусное — нигде нет нормальной документации для этого действа.
    И я так и не понял, как в браузере не дать пользователю выбрать не ГОСТ-овский токен


    1. Sonkkorh Автор
      11.10.2018 19:06

      простой фильтрацией certAlgorithmFriendlyName.match ГОСТ или GOST насколько я понял имя зависит от языкового пакета Windows или что там установлено на клиенте


    1. Sonkkorh Автор
      11.10.2018 19:16

      Выглядит просто, но собрать это всё вместе чтоб работало… команда работала, искала материалы, по крупицам собирала информацию в доступных источниках.
      Могу сказать, что это было не очень просто, но в итоге заработало как надо.


  1. alexhott
    11.10.2018 19:24

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


    1. vanderPon
      11.10.2018 19:33

      Так тут речь о встреченной подписи:

      вставляем подпись в документ


      1. Sonkkorh Автор
        11.10.2018 19:34

        да, подпись вставляем прямо в PDF


    1. Sonkkorh Автор
      11.10.2018 20:02

      хм… у нас предполагается двусторонее подписание. Слишком сложно все эти подписи за собой таскать, да и как простому пользователю объяснить, что вот этот файлик и есть твоя подпись. А вот когда в PDF-нике есть фиолетовая печать с ФИО это всем понятно


    1. vanderPon
      11.10.2018 21:07

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


  1. saipr
    12.10.2018 08:33

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

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


  1. vanderPon
    12.10.2018 17:46

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


    1. BugM
      12.10.2018 18:03

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


      1. vanderPon
        12.10.2018 19:18

        О преимуществах встроенной подписи не спорю.
        Коллега спрашивал «бывает ли чтобы 2 подпись делала первую невалидной» — с кривыми руками при работе со встроенными подписями — легко. Это не отменяет принципиальных плюсов встроенных