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 не испускается: нет пути подгрузки, с которого можно репортить прогресс
Это дизайнерская позиция, которую вообще стоит защищать. Валидатор, который не может проверить отзыв, должен так и сказать. Репорт непроверенного сертификата как не отозванного — самый частый способ, которым инструменты проверки подписей водят пользователей за нос, и это ровно тот класс путаницы, который разобран в статье почему валидаторы отвергают подписи 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-хранилищем каждой платформы
Что бы вы ни поставили, логируйте PadesCmsVerificationBackendName рядом с каждым записанным вердиктом. Сохранённый результат валидации без бэкенда, который его выдал, позже не воспроизвести: три статуса означают чуть разное в зависимости от того, какой стек ответил. Слой инспекции подписей поверх всего этого, включая то, как репортятся уровни PAdES, разобран в инспекции цифровых подписей PDF и уровней PAdES
Всё это поставляется исходниками с Delphi-компонентом PDFium, и здесь это важнее обычного: для валидатора подписей возможность прочитать, какие именно флаги ставит бэкенд и какие проверки он пропускает, — не приятный бонус, а единственный способ узнать, что на самом деле обещает зелёная галочка в вашем приложении