Техническая статья

PDF Library for Delphi: PAdES signing and validation в Delphi

Проверка одной подписи PAdES означает проверку трёх независимых вещей, а зелёная галочка в просмотрщике сообщает вам только о третьей. Во-первых, массив /ByteRange должен покрывать правильные байты: указанные в нём диапазоны обязаны восстанавливать в точности тот входной набор данных, по которому был взят дайджест CMS, без единого подписанного байта за их пределами. Во-вторых, сертификат внутри CMS должен выстраиваться в цепочку до доверенного вами корня и нести подписанный атрибут signing-certificate, которого требует PAdES. В-третьих, если профиль заявляет метку времени, токен RFC 3161 должен привязывать значение подписи к моменту времени до истечения срока действия сертификата. Acrobat сворачивает все три пункта в одну иконку; проверяющий соответствие инструмент держит их раздельно, и так же должен поступать код, который создаёт эти файлы. losLab PDF Library (PDF Library for Delphi) даёт вам сторону подписания этой схемы, повторное встраивание метки времени и аудиторские вызовы для изучения ByteRange до того, как вы ему доверитесь

Одно различие сбивает с толку почти каждую первую реализацию PAdES, так что его стоит проговорить ещё до всякого кода. Подпись, записанная с /SubFilter /adbe.pkcs7.detached, — совершенно исправная подпись по ISO 32000-1 §12.8, которую Acrobat объявит действительной. Но это не подпись PAdES, потому что ETSI EN 319 142-1 требует ETSI.CAdES.detached на каждом базовом уровне. Инструмент проверки соответствия eIDAS отклонит первую и примет вторую, хотя криптография в обоих случаях идентична. Профиль — это заявление, которое документ делает о самом себе, и сделать это заявление правильно — один вызов в PDF Library for Delphi

Что превращает подпись PDF в подпись PAdES

ETSI EN 319 142-1 определяет четыре базовых уровня, надстроенных над форматом CMS. PAdES-B-B — точка входа: подпись CAdES в поле подписи PDF с SubFilter ETSI.CAdES.detached и подписанным атрибутом signing-certificate. PAdES-B-T добавляет метку времени RFC 3161 поверх значения подписи, доказывая, что подпись существовала до момента, который никто не может подделать задним числом. PAdES-B-LT встраивает сертификаты, CRL и ответы OCSP, нужные для проверки, в Document Security Store, так что файл остаётся проверяемым и после того, как выпустивший его удостоверяющий центр свернёт свою инфраструктуру. PAdES-B-LTA венчает эту иерархию меткой времени документа, которая заново защищает накопленные доказательства по мере того, как алгоритмы слабеют

PDF Library for Delphi отображает эти понятия на свой API sign-process. Маркер профиля — SetSignProcessCustomSubFilter. Если ваша политика требует указания типа обязательства (proof of origin, proof of approval или один из остальных идентификаторов ETSI с номерами от 1 до 6), это делается через SetSignProcessCommitmentType. Явная политика подписи присоединяется через SetSignProcessSignaturePolicy, которая принимает OID политики и её дайджест. Одно значение по умолчанию заслуживает внимания: если оставить алгоритм дайджеста в режиме auto, библиотека выбирает SHA-256 для подписей ETSI и adbe.pkcs7.detached и откатывается к SHA-1 только на устаревшем пути adbe.pkcs7.sha1. Всё равно задавайте его явно. Аудиторы спрашивают, какой хеш вы использовали, и явное значение в коде защитить проще, чем значение по умолчанию, для объяснения которого нужно лезть в руководство

Лестница базовых уровней PAdES B-B, B-T, B-LT и B-LTA, построенных с PDF Library for Delphi: каждый уровень добавляет метки времени, свидетельства DSS или обновляемую метку времени документа поверх ядра ETSI.CAdES.detached
Каждый базовый уровень ETSI надстраивает ещё одну гарантию над тем же ядром CAdES — от подписанных атрибутов до возобновляемой метки времени документа

Создание базовой подписи

Плоский API управляет подписанием как одноразовым конечным автоматом: открыть процесс над исходным файлом, настроить его, завершить в выходной файл, прочитать код результата. Последовательность ниже создаёт подпись PAdES-B-B с SHA-256. Самая важная строка вообще не имеет отношения к самой подписи. Это намеренно завышенный резерв под /Contents, потому что это единственное, что нельзя изменить позже, если к этой подписи когда-нибудь понадобится добавить метку времени

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('invoice.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Sig1');
    Pdf.SetSignProcessPFXFromFile(SignId, 'company.pfx', PfxPassword);
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Vienna', 'billing@example.com');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 2);          // SHA-256
    Pdf.SetSignProcessReserveContentsBytes(SignId, 8192);  // место для будущей метки времени
    Pdf.EndSignProcessToFile(SignId, 'invoice-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

NewSignProcessFromFile возвращает 0, когда исходный файл вообще не удаётся открыть. После этого GetSignProcessResult различает режимы сбоя, которые реально встречаются в продакшне: 4 означает неверный пароль PDF, 7 — неверный пароль PFX, 9 — файл сертификата без закрытого ключа, 10 — недоступный для записи путь вывода, 11 — сбой при наложении байтов подписи. Запись числового кода рядом с именем входного файла превращает расплывчатое обращение в поддержку в диагностику на одну минуту

Добавление метки времени RFC 3161, которую библиотека сама за вас не получит

PDF Library for Delphi не поставляется с клиентом TSA, и это осознанная граница, а не пробел. Библиотека вычисляет хеш, который должен заверить орган штампов времени, а затем повторно встраивает дополненный CMS; HTTP-обмен и хирургия над CMS между этими двумя шагами остаются на стороне вызывающего кода. У этого разделения есть жёсткая техническая причина. Управляющий вызов Windows CryptoAPI, номинально добавляющий неподписанные атрибуты, CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR, завершается ошибкой CRYPT_E_INVALID_INDEX на отсоединённой раскладке SignedData, которую использует PAdES. Поэтому расширенный CMS обязан приходить из кодировщика CMS, находящегося под вашим собственным контролем. Ни одна библиотека не способна незаметно вклеить токен одним системным вызовом, а любая, что утверждает обратное, просто проводит эту хирургию там, где вы её не видите

Конвейер добавления метки времени RFC 3161 к подписи PAdES в Delphi: хеширование и встраивание PDF Library for Delphi отделены от запроса TSA вызывающего и перекодирования CMS внутри зарезервированного пространства /Contents
Библиотека хеширует и повторно встраивает, тогда как ваш код достаёт токен и проводит CMS-хирургию, а результат должен уложиться в 8192-байтовый резерв /Contents
var
  Pdf: TPDFlib;
  StsId: Integer;
  HashHex, TstDer, TsAttr, AugmentedCms: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    StsId := Pdf.NewPAdESSignatureTimeStampProcessFromFile('invoice-signed.pdf', '');
    Pdf.SetPAdESSignatureTimeStampField(StsId, 'Sig1');
    Pdf.SetPAdESSignatureTimeStampDigestAlgorithm(StsId, 2);
    HashHex := Pdf.GetPAdESSignatureValueHashHex(StsId);
    // оба вызова ниже — код приложения: HTTP POST к вашему TSA,
    // и повторное кодирование CMS, присоединяющее токен как неподписанный атрибут
    TstDer := RequestTimeStampToken(HashHex);
    TsAttr := Pdf.BuildPAdESSignatureTimeStampAttribute(TstDer);
    AugmentedCms := AttachUnsignedAttribute(Pdf.GetPAdESSignatureCMSBytes(StsId), TsAttr);
    Pdf.SetPAdESSignatureCMSBytes(StsId, AugmentedCms);
    Pdf.EndPAdESSignatureTimeStampProcessToFile(StsId, 'invoice-bt.pdf');
    if Pdf.GetPAdESSignatureTimeStampProcessResult(StsId) <> 1 then
      raise Exception.Create('timestamp embedding failed');
    Pdf.ReleasePAdESSignatureTimeStampProcess(StsId);
  finally
    Pdf.Free;
  end;
end;

Следите здесь за кодами результата: 12 означает, что названного поля подписи не существует, 11 — что существующий CMS не удалось разобрать, а 13 — что дополненный CMS больше не помещается в зарезервированный заполнитель /Contents. Код 13 — это тот, что действительно больно бьёт, потому что единственное исправление — повторное подписание: типичный токен метки времени вместе с цепочкой сертификатов занимает от 4 до 6 КБ, а резерв в 8192 байта, сделанный на шаге B-B, существует именно для того, чтобы этому шагу было куда приземлиться

Проверка начинается с ByteRange, а не с цепочки сертификатов

Зелёная галочка в просмотрщике — это решение о доверии, вынесенное относительно хранилища сертификатов конкретной машины, а не структурный вердикт о самом файле. Программная проверка должна начинаться ниже, с вопроса, который инкрементальные обновления делают тонким: какие байты на самом деле покрывает каждая подпись? Каждое из обсуждаемых здесь улучшений — будь то вторая подпись, словарь DSS или метка времени документа — приходит через инкрементальное обновление, и каждое такое обновление добавляет байты за пределами /ByteRange более ранней подписи. Эти добавленные байты законны. Валидатору всё равно приходится классифицировать их относительно политики изменения документа, а уровень DocMDP для конкретного поля, в котором живёт эта политика, читается через GetSignatureDocMDPLevelByName

Байтовый аудит компоновки подписанного PDF в Delphi: охваченные диапазоны ByteRange, исключённые байты /Contents, добавленные инкрементальные обновления вне диапазона и вердикт о покрытии относительно размера файла
Два покрытых диапазона без собственных байтов подписи рассказывают правду о покрытии, а дописанные обновления классифицируются по политике DocMDP, а не внушают страх
var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  I: Integer;
  B0, B1, B2, B3, FileSize: Int64;
begin
  FileSize := TFile.GetSize('invoice-bt.pdf');  // до Open: SignDoc удерживает блокировку совместного доступа
  Doc := TPDFlibSignDoc.Create;
  try
    if not Doc.Open('invoice-bt.pdf', '', False) then
      raise Exception.Create('cannot open for audit');
    Names := TStringList.Create;
    try
      Doc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
        if Doc.GetSignatureValueObjNum(Names[I]) > 0 then   // >0 означает, что подпись действительно проставлена
        begin
          B0 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
          B1 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
          B2 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
          B3 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
          if (B0 = 0) and (B2 + B3 = FileSize) then
            Writeln(Names[I], ': covers the file to EOF')
          else
            Writeln(Names[I], ': earlier revision, or unexpected ByteRange layout');
        end;
    finally
      Names.Free;
    end;
    Doc.Close;
  finally
    Doc.Free;
  end;
end;

В этом пути аудита живут две ловушки. TPDFlibSignDoc.Open удерживает файл под эксклюзивной блокировкой совместного доступа, так что валидатору, которому ещё и нужно захешировать сырые байты файла для проверки CMS, приходится прочитать файл в память до открытия его для аудита. Поменяйте порядок местами — и чтение упрётся в блокировку, которую вы сами же и установили. Вторая ловушка тихая, а не громкая: аналог плоского API, GetSignProcessByteRange, возвращает Integer, тогда как исходные смещения имеют тип Int64, так что после 2 GB плоский вызов молча усекает значение, и именно поэтому в этом примере смещения извлекаются через класс аудита. Стоит назвать и одно отсутствие. У плоского слоя вообще нет обёртки VerifySignature. Криптографические вердикты приходят из класса TPDFlibSignatureVerifier, который возвращает vsValid, vsInvalid или vsUnknown, либо от внешнего валидатора, которому уже доверяет ваша политика соответствия

Долгосрочная проверка: DSS, VRI и метка времени документа

PAdES-B-LT существует потому, что инфраструктура отзыва смертна. ETSI EN 319 142-1 §5.4.2.2 определяет Document Security Store: словарь на уровне документа, несущий сертификаты, CRL и ответы OCSP, опционально индексированные по каждой подписи через записи VRI, ключом для которых служит хеш поля /Contents соответствующей подписи. Поток PDF Library for Delphi повторяет ту же схему, что и для метки времени. NewPAdESDSSProcessFromFile открывает процесс; AddPAdESDSSCertificate, AddPAdESDSSCRL и AddPAdESDSSOCSP принимают блобы DER; AddPAdESDSSVRI привязывает выбранный материал к одной подписи; EndPAdESDSSProcessToFile записывает всё это как инкрементальное обновление. Сложная часть остаётся на вашей стороне. Получение материала об отзыве и оценка того, достаточно ли он свеж, чтобы его стоило встраивать, — задача вызывающего кода. Библиотека гарантирует, что словари структурно соответствуют стандарту; она не может гарантировать, что ваш респондер OCSP сказал правду

Конечная архивная точка, B-LTA, добавляет метку времени документа: отдельное поле подписи, тип которого — DocTimeStamp, а не Sig, создаваемое через SetSignProcessDocTimeStamp с зарезервированной длиной подписи. Она не заменяет метку времени подписи, поставленную на шаге B-T. Метка времени подписи доказывает, когда существовала конкретная подпись; метка времени документа защищает весь файл целиком, включая доказательства DSS, и это тот элемент, который долгосрочный архив обновляет каждые несколько лет по мере ослабления алгоритмов. Зрелый архивный профиль несёт оба варианта. Для ридеров, появившихся раньше этих структур, TPDFlibSignDoc.EnsurePAdESExtensions записывает расширение разработчика ESIC в каталог документа, объявляя, что файл использует функции, определённые ETSI

Одну реакцию на всё это стоит предупредить заранее, потому что выглядит она как баг, а на деле им не является. Просмотрщик нередко сообщает «действительность неизвестна» о файле, структура PAdES которого совершенно корректна. Доверие и структура — независимые оси. Просмотрщик просто не может выстроить цепочку от подписанта до корня, которому доверяет на этой конкретной машине, а это обычное дело для частных удостоверяющих центров и тестовых сертификатов, даже когда и аудит ByteRange, и проверка CMS проходят успешно. Правильное исправление — корректно распространить корневой сертификат либо сверяться со списками доверия ЕС, когда реальная цель — получение квалифицированного статуса eIDAS, а не трогать код подписания

За взглядом со стороны аудита — то есть перечислением полей подписи по всему корпусу файлов, выгрузкой раскладок ByteRange и массовым чтением уровней DocMDP — обратитесь к сопутствующему материалу о рабочем стенде соответствия и подписания. Подписанные документы, которые должны также удовлетворять архивной политике, находят своё место в процессе, описанном в статье предпечатная проверка PDF/A и PDF/UA в Delphi. Полная документация по API и пробные версии для скачивания — на странице продукта losLab PDF Library для Delphi