Вся соль в том, что надо использовать подпись в формате 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 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 на этом всё. Проверка будет описана в продолжении статьи. Надеюсь, она будет…
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-
Комментарии (29)
BugM
11.10.2018 16:07+1Вы в курсе что это все абсолютно незаконно? Нельзя подписывать ЭПЦ пользователя то что пользователь не видит.
И даже подменять файлики нельзя. То есть показать одно а подписать другое нельзя.Sonkkorh Автор
11.10.2018 17:24Можно подробнее насчёт показать одно, а подписать другое. Где вы такое усмотрели?
И где тут идёт подмена файлов?
Берём PDF добавляем в него контейнер, считаем хеш и отправляем на подпись пользователю, пользователь присылает подпись, мы ее вставляем в этот же самый PDF.
Где подмена?BugM
11.10.2018 17:57Есть закон. Ссылку не дам, но формулируется так: «Пользователь может подписать только тот документ который он видит.»
Есть несколько типовых сценариев подписи pdf:
1. С сервера прилетает файл. Показывается на клиенте. По кнопке Подписать считаем хеш и подписываем.
2. С сервера прилетаем файл. Показываем его. По кнопке Подписать запрашиваем хеш с сервера и подписываем.
3. С сервера прилетает некое изображение документа. По кнопке Подписать запрашиваем хеш и подписываем.
4. По кнопке Подписать запрашиваем хеш и подписываем.
Сценарии идут в порядке понижения законности.
Первый абсолютно законен.
Второй относительно законен, но не имеет смысла. Хеш посчитать быстрее чем отрендерить документ.
Третий незаконен, но иногда бывает. Телефоны плохо дружат с большими файлами. Все выкручиваются как могут.
Четвертый абсолютно незаконен.
У вас статья о четвертом сценарии.
В процессе решения задачу решили усложнить для нас, и уменьшить объем передаваемых данных на клиент. Передавать только hash документа, но не сам документ.
Не надо так делать.Sonkkorh Автор
11.10.2018 18:37До и после подписания юзер может выгрузить файл и посмотреть его
Мы же не в «тёмную» ему хеш отправляем
Ну и, конечно, ссылочку на правовой документ было бы неплохо увидетьvanderPon
11.10.2018 19:34Если пользователь может выгрузить файл до подписания «as is», значит он технически может рассчитать на своей машине хеш и убедиться при помощи «независимых решений», что он подписал строго тот файл, который скачал.
Значит ли это, что если есть решение по варианту 4, но со ссылкой, то это законно?
Если это не законно, то как многие организации (в том числе банки) показывают, например, соглашение о персональных данных или лицензионное соглашение в виде ссылки? Ведь тогда тоже пользователь соглашается с тем, что может не видеть в момент соглашения?BugM
11.10.2018 21:21Да и вообще почти все вне общения с государством используют не ГОСТ подпись. Много чего работает, пока юристы к этому прикапываться не начнут.
У тс гост подпись. Значит государство очень рядом. И надо соответствовать.
BugM
11.10.2018 21:19"Может" тут не работает.
Вы даете хеш в темную. Что там за документ подписывается, да черт его знает. Он даже не передается на устройство пользователя. Вот вам циферка. Подпишите ее.
Ссылочку как уже и писал не дам. Я далек от юристов. А это их вопрос. Я только вывод знаю.
vanderPon
12.10.2018 01:14Хеш на то и хеш, что по нему можно однозначно определить, соответствует ли он файлу F или нет. Мы даём пользователю доступ к файлу F и отдельно загружаем с сервера хеш X(F). В чем отличие от того, что мы загружаем файл и на стороне клиента вычисляем X(F)? Если подозревать, что сервер сделал хеш другого файла, то это легко может быть проверено… В любом случае А) проведенное автором решение не теряет своей ценности, и с доработками интересно и для случая расчета хеша на стороне клиента: Б) вывод ваших юристов не понял совсем… Если они говорят, что нельзя дать документ на подпись, не представляя к нему доступ, — понятно, без вопросов. Но если речь только о том в каком режиме доступ к файлу организовать — совсем не понятно. Так можно говорить, что пусть файл и скачали мне на сторону клиента для подписи, но я все равно его не выгружал и не открывал документ — значит подписал вслепую и документ не действителен.
Есть файл. Есть хеш и стороны могут доказать, что это хеш файла, есть подпись и стороны могут доказать, что подпись хеша. Файл был доступен контрагенту до подписания. Где слабое звено?BugM
12.10.2018 13:10Я не про подмену хеша говорю. Ее организовать можно в любом случае. Для клиента нет адекватного способа проверить до подписания что именно он подписывает.
Клиент должен доверять софту. Софт должен заботиться о безопасности. Это все по умолчанию.
Я именно про тонкости организации самого процесса. Про подробности я говорить не готов, не в курсе. Допускаю что юристы просто перестраховались. Маловероятно, но возможно.
Решение само по себе вполне типичное. Проблема только во внедренной ГОСТовой подписи. Весь типовой софт будет ругаться на эти подписи. Но с этим ничего не сделать.
swamp_scout
11.10.2018 19:04Как все просто выглядит. Я реализовывал подпись документов docx на фронтенде, написанном на питоне — вот там была жесть. Хеш посчитай, xml нормализуй, хеш отзеркалируй и т.п. И самое гнусное — нигде нет нормальной документации для этого действа.
И я так и не понял, как в браузере не дать пользователю выбрать не ГОСТ-овский токенSonkkorh Автор
11.10.2018 19:06простой фильтрацией certAlgorithmFriendlyName.match ГОСТ или GOST насколько я понял имя зависит от языкового пакета Windows или что там установлено на клиенте
Sonkkorh Автор
11.10.2018 19:16Выглядит просто, но собрать это всё вместе чтоб работало… команда работала, искала материалы, по крупицам собирала информацию в доступных источниках.
Могу сказать, что это было не очень просто, но в итоге заработало как надо.
alexhott
11.10.2018 19:24подписывать файл присоединенной подписью мало смысла, пк на котром откроют пдф будет говорить об ошибке сертификата, так как почти ни у кого нет крипто про и рлагина. Отсоединенную сразу ругать не будет и можно в сопровожиловке отправить за проверкой на госуслуги
Sonkkorh Автор
11.10.2018 20:02хм… у нас предполагается двусторонее подписание. Слишком сложно все эти подписи за собой таскать, да и как простому пользователю объяснить, что вот этот файлик и есть твоя подпись. А вот когда в PDF-нике есть фиолетовая печать с ФИО это всем понятно
vanderPon
11.10.2018 21:07Отсоединную подпись могут и потерять. И что это такое "зачем мне это файл" многие не понимают. Во всяком случае ФНС использует встроенные
saipr
12.10.2018 08:33При этом предыдущие подписи должны оставаться валидными.
А что бывает по-другому? Странно.
vanderPon
12.10.2018 17:46При отсоединенных подписях проблем нет — каждая новая подпись — независимый файл, никак не влияющий на исходный.
Со встроенной прописью в момент нанесения второй файл технически отличается — в него уже добавлена первая подпись + визуализация этойBugM
12.10.2018 18:03Встроенные позволяют подписывать уже подписанный документ. С отсоединенными такое провернуть сложно. Так чтобы первая подпись затеряться где-нибудь потом не смогла.
vanderPon
12.10.2018 19:18О преимуществах встроенной подписи не спорю.
Коллега спрашивал «бывает ли чтобы 2 подпись делала первую невалидной» — с кривыми руками при работе со встроенными подписями — легко. Это не отменяет принципиальных плюсов встроенных
TiesP
Подскажите, а как-то видно, что документ pdf подписан электронной подписью (при распечатке)? Либо наличие подписи можно проверить, только имея программу и этот файл pdf?
Sonkkorh Автор
Есть немного кода, который я из статьи убрал, там генерируется видимая подпись. У нас она выглядит как прямоугольная печать. Различные PDF-просмотровщики позволяют так же проверить подпись. Но с ГОСТ всё сложно, нужны плагины
vanderPon
Наличие «видимой подписи» как раз ничего не доказывает юридически. Это картинка, которую может кто угодно нафотошопить. Доказательством подписания документа является прохождение проверки ЭП в нем сертифицированной (кажется ФСБ) программой.
Можно проверить файл в Acrobat Reader ВС (установив и настроив плагин от КриптоПРО). Также есть несколько сайтов на которых можно бесплатно проверить ЭП в документе, так что независимая проверка обеспечена
TiesP
Да, насчет подтверждения подписи понятно. Просто "бухгалтерам" иногда требуется предоставить копию документа третьему лицу. Допустим — это какой-то бухгалтерский документ в формате pdf, подписанный электронной подписью. Как в этом случае должно всё работать? Просто отправлять файл и третье лицо пускай само все проверяет — есть подпись, или нет подписи? Как-то это кажется не совсем удобно.
vanderPon
А как с обычной подпись на бумаге? Вы отдаете документ и третье лицо само разбирает есть ли, чья подпись. В случае ЭП ему (третьему лицу) много проще — они может проверить кто именно подписал