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

Верификация PDF-подписей через OpenSSL в PDFium VCL

PDFium VCL считает CMS-верификацию заменяемым бэкендом за интерфейсом IPdfCmsVerifier, поэтому PAdES-валидатор может работать на Windows через CryptoAPI, на macOS через Keychain, а всюду, где есть OpenSSL, — через ConfigureSslCmsVerifier. Интерфейс маленький. Но три поведения OpenSSL под ним выдают уверенные неверные ответы, если реализовать его наивно

Мотивация становится очевидной, как только Delphi-приложение покидает Windows. Проверка подписей — одна из немногих областей, где криптостек платформы не является деталью реализации: он решает, каким сертификатам доверять, какие алгоритмы существуют и что означает отзыв. Захардкодите конкретный стек — код перестаёт переноситься. Сделаете абстракцию плохо — каждая платформа отдаёт ответ своей формы, который вызывающему не с чем сравнить

Что абстракция на самом деле обязана нести

Две формы проверки и три независимых вердикта. Подпись PDF — отсоединённая: подписанное содержимое — это два диапазона байтов по сторонам от дыры /Contents, поэтому VerifyDetached принимает два сегмента, а не один буфер. Токен метки времени — присоединённый, он несёт собственное содержимое, поэтому VerifyAttached принимает только DER

Результат распадается на три статуса, потому что они отвечают на три разных вопроса и могут расходиться. SignatureStatus говорит, подписаны ли байты ключом из сертификата подписанта. TrustStatus — цепляется ли этот сертификат к тому, чему вы доверяете. RevocationStatus — был ли сертификат действителен в соответствующий момент. Документ с математически безупречной подписью от сертификата, о котором вы никогда не слышали, — действителен, недоверен и неизвестен одновременно, и схлопывание всего этого в один boolean — типичный путь, которым валидаторы начинают врать пользователям

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, может быть пусто
  ConfigureSslCrls(LoadFreshCrls);                // DER, может быть пусто
  ConfigureSslCmsVerifier;                        // ставим бэкенд

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout выглядит как диковинка, но им не является. Каждый код ошибки OpenSSL и каждый флаг хранилища пересекают границу как C-тип unsigned long — четыре байта на Windows и восемь на Linux и macOS. Объявите его фиксированным 32-битным типом — код работает на Windows, а затем молча читает половину значения на LP64. Репорт предположенных ширин строкой, которую можно проверить ассертом в тесте, превращает целый класс платформенного дрейфа ABI в проверку из одной строки. Кто продирался через ту же проблему с CK_ULONG в биндинге PKCS#11, узнает её мгновенно; та история — в упаковке структур PKCS#11 и ширине CK_ULONG

Почему второй проход верификации видит пустое содержимое?

Потому что CMS_verify читает BIO отсоединённого содержимого до конца файла, а прочитанный BIO сам за вас не перематывается. Верификация в два прохода — разумный дизайн: сперва только криптографическая подпись с подавленной оценкой цепочки, затем полная оценка, — и отказывает она на редкость обманчиво, если оба прохода делят один BIO

Второй проход получает ноль байтов содержимого. В отсоединённом режиме это не ошибка: пустой буфер содержимого — легальный вход. Дайджест просто не сходится, и отказ всплывает как отказ построения цепочки, а не содержимого, что отправляет вас инспектировать сертификаты и хранилища доверия, тогда как настоящая проблема — позиция в потоке. Перестраивайте memory-BIO через BIO_new_mem_buf на каждый проход. Это стоит одной аллокации и убирает саму возможность

Что флаг no-verify подавляет, а что нет

CMS_NO_SIGNER_CERT_VERIFY подавляет оценку цепочки, а не поиск сертификата подписанта. Внутри OpenSSL резолвит и присоединяет сертификаты подписанта до того, как посмотрит на флаг, поэтому после первого прохода с этим флагом подписант уже доступен, и его идентификаторы алгоритмов можно читать сразу. Запускать второй полный проход верификации только ради сертификата подписанта не нужно — хотя именно это и навевает имя флага

С этим идёт одно правило владения. Ссылка на подписанта принадлежит CMS-структуре, и освобождать её самостоятельно нельзя. Она действительна, пока действительна структура, а её освобождение даёт порчу памяти, чей симптом вылезет совсем в другом месте — обычно при очистке постороннего объекта

Почему включение проверки CRL отвергает все подписи?

Потому что OpenSSL сверяет CRL только с тем, что хранилище уже содержит, и ничего не подгружает по своей инициативе. Он не ходит по CRL distribution points и не умеет OCSP. Поставьте X509_V_FLAG_CRL_CHECK на хранилище без единого CRL — и каждая цепочка падает с невозможностью получить CRL сертификата. Выглядит это как проверка отзыва, которая работает и находит проблемы. На самом деле это проверка отзыва, которая вообще не запускалась

Поэтому бэкенд ставит флаг, только когда ConfigureSslCrls реально передала хотя бы один CRL. Без него RevocationStatus возвращается как pcvsUnsupported — честное заявление, что вопрос остался без ответа. По той же причине OnlineRetrieval на этом бэкенде не даёт эффекта и чекпоинт pcvstOnlineRetrieval не испускается: нет пути подгрузки, с которого можно репортить прогресс

Диаграмма CMS-верификатора OpenSSL в PDFium VCL с тремя ловушками: общий BIO содержимого, дочитанный до конца файла, оставляет второй проход верификации с нулём байтов, CMS_NO_SIGNER_CERT_VERIFY подавляет оценку цепочки, но не поиск сертификата подписанта, а проверка CRL на пустом хранилище отвергает каждую цепочку, хотя проверка отзыва ни разу не запускалась
Каждая ловушка даёт уверенный неверный вердикт: позиция в потоке маскируется под отказ доверия, флаг no-verify подавляет меньше, чем обещает его имя, а ни разу не запускавшаяся проверка отзыва выглядит как проверка, нашедшая проблемы

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

// Чекпоинты позволяют UI показывать, какая стадия выполняется, и говорят,
// какие стадии бэкенд реально проходит
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Три вердикта читайте по отдельности — им разрешено расходиться
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Биндинг к библиотеке, чью версию нельзя зафиксировать

Между 1.0 и 1.1 OpenSSL переименовал свои аксессоры стека, поэтому у одной и той же логической функции два возможных экспортных имени — в зависимости от сборки, которая оказалась на хосте. Биндинг сперва резолвит новое имя и откатывается к старому, а отсутствующий символ записывает, только когда не резолвится ни одно. Это правильная форма для любого динамического биндинга к библиотеке, которую вы не поставляете: предпочитать актуальные имена, терпеть исторические и репортить только подлинное отсутствие

SslMissingSymbols — то, что превращает неудавшуюся загрузку в диагностируемое событие. Непустой результат на хосте, где libcrypto явно установлен, означает, что установленная версия старше API, на который нацелена эта сборка, — а это совсем другой разговор в поддержке, чем отсутствующая библиотека. ConfigureSslLibraryPath закрывает другой частый случай: хост с несколькими сборками OpenSSL, где та, что лежит на дефолтном пути поиска, — не та, что вам нужна

Выбор бэкенда под каждую платформу

Практичная схема — выбирать на старте и записывать, кто ответил. На Windows платформенный бэкенд интегрируется с хранилищами сертификатов, которыми предприятие уже управляет, — обычно это и нужно. На macOS Keychain-бэкенд ложится в то же рассуждение, он разобран в верификации PDF-подписей на macOS с SecTrust. OpenSSL — переносимый вариант, и это также правильный выбор, когда политика валидации нужна идентичная на всех платформах, а не следующая за trust-хранилищем каждой платформы

Диаграмма абстракции IPdfCmsVerifier в PDFium VCL: VerifyDetached несёт два диапазона байтов вокруг дыры Contents, VerifyAttached — токены меток времени; три независимых вердикта SignatureStatus, TrustStatus и RevocationStatus; платформенные бэкенды, выбираемые на старте через CryptoAPI, SecTrust или ConfigureSslCmsVerifier
Интерфейс несёт две формы проверки и три вердикта, потому что они отвечают на разные вопросы и могут расходиться, а установленный бэкенд записывается рядом с каждым вердиктом, чтобы сохранённые результаты можно было воспроизвести

Что бы вы ни поставили, логируйте PadesCmsVerificationBackendName рядом с каждым записанным вердиктом. Сохранённый результат валидации без бэкенда, который его выдал, позже не воспроизвести: три статуса означают чуть разное в зависимости от того, какой стек ответил. Слой инспекции подписей поверх всего этого, включая то, как репортятся уровни PAdES, разобран в инспекции цифровых подписей PDF и уровней PAdES

Всё это поставляется исходниками с Delphi-компонентом PDFium, и здесь это важнее обычного: для валидатора подписей возможность прочитать, какие именно флаги ставит бэкенд и какие проверки он пропускает, — не приятный бонус, а единственный способ узнать, что на самом деле обещает зелёная галочка в вашем приложении