PDFium VCL теперь может достроить цепочку PDF-подписи и проверить revocation по сети на своём OpenSSL-бэкенде: когда включён OnlineRetrieval, ConfigureSslCmsVerifier ставит верификатор, который докачивает недостающие промежуточные сертификаты с URL AIA caIssuers и CRL с точек дистрибуции CRL, внутри фиксированного бюджета времени, запросов и байтов на каждый вызов верификации. Скачанные сертификаты — всегда лишь материал для цепочки. Доверие по-прежнему приходит исключительно из системного хранилища и настроенных вами якорей
Брешь, которую это закрывает, вскрывается при первой же валидации настоящих PDF на Linux-сервере. Изрядная доля подписантов вкладывает в CMS только собственный leaf-сертификат, поэтому OpenSSL не дотягивается до корня, TrustStatus возвращается невалидным, а revocation вообще не запускается, потому что цепочка так и не стала доверенной. До v3.121.0 OpenSSL-бэкенд, описанный в статье о верификации PDF-подписей с OpenSSL в PDFium VCL, был строго офлайновым, и OnlineRetrieval на него не действовал. Одно стоит сказать сразу: сам движок PDFium не делает CMS-верификации вовсе, так что каждое правило ниже живёт в PAdES-слое компонента и его OpenSSL-биндинге, где его можно почитать
В каком порядке OpenSSL-бэкенд верифицирует, качает и проверяет?
Сперва целостность, затем доверие, затем revocation, и сеть трогается только между шагами, которые её нуждаются. VerifyCmsWithSsl проверяет подпись CMS и signed attributes (RFC 5652) с подавленной оценкой цепочки, и если это падает, возвращается немедленно, до того как fetch-сессия вообще родилась, так что документ со сломанными байтами не порождает ни одного исходящего запроса. И только если цепочка затем провалилась и OnlineRetrieval включён, он идёт по ссылкам AIA и верифицирует снова. Точки дистрибуции CRL качаются лишь после того, как цепочка стала доверенной, потому что CRL, висящая на недоверенном пути, не доказывает ничего. Три вердикта остаются раздельными на всём протяжении: валидная подпись с неполной цепочкой по-прежнему репортится как валидная подпись
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER; единственное доп. доверие
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // по умолчанию ptnpOffline
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // на один вызов верификации
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
Почему скачанные сертификаты никогда не попадают в trust store?
Потому что URL приходят из сертификата, который валидируется, а выбрал их подписант. Запись authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) — подсказка, где живёт issuer, и ничего больше. Если бы то, что отвечает на тот URL, попадало в хранилище trust-якорей, любой мог бы подписать самодельным ключом, направить AIA на собственный сервер и получить зелёный вердикт. Поэтому RetrieveIntermediates передаёт каждый распарсенный сертификат в CMS_add1_cert, который кладёт его в недоверенный набор этой единственной структуры CMS, и OpenSSL всё ещё обязан построить от него путь к якорю, которого настроили вы или который уже держит системное хранилище. Есть и более тихая причина: аргумент-сертификат CMS_verify — не взаимозаменяемая замена сертификатам, встроенным в CMS, так что дописывание в саму CMS — надёжный путь
Цикл закачки нарочно узок. RetrieveIntermediates делает максимум 4 раунда, каждый собирает URL caIssuers со всех сертификатов, теперь лежащих в CMS, и останавливается, как только раунд не добавил ничего или время вышло. Ответ обязан декодироваться через d2i_X509 как единственный DER-сертификат, съедающий всё тело целиком; хвостовые байты отвергаются, а PKCS#7-пачка из одних сертификатов, отданная с URL .p7c, пропускается, а не распаковывается. Метод доступа OCSP в том же расширении AIA игнорируется, поскольку этот бэкенд на OCSP не говорит. На стороне revocation RetrieveCrls читает только URI fullName каждого DistributionPoint (RFC 5280 §4.2.1.13) из сертификатов CMS и настроенных якорей, а скачанные CRL уходят во второе, независимое X509_STORE с проверкой CRL по всей цепочке, так что отсутствующая или протухшая CRL меняет RevocationStatus, ни разу не тронув TrustStatus
// Сокращено из VerifyCmsWithSsl (FPdfCryptoSsl.pas); настройка BIO опущена.
// Каждый вызов _CMS_verify получает свежий content BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // сломанная подпись: сети нет вовсе
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, только untrusted
// верифицируем цепочку снова против того же хранилища якорей
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// второе, отдельное хранилище: сконфигурированные CRL плюс скачанные
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
Сколько стоит один вызов верификации максимум?
Фиксированный потолок, поддерживаемый одним TPdfCryptoFetchSession, который шаги AIA и CRL одного вызова верификации делят. Лимиты — константы в FPdfCryptoHttp, а не пожелания:
- Время:
UrlRetrievalTimeoutMs, который по умолчанию 15000 и вTPdfCmsVerifyOptions.Default, и вTPadesTrustValidationOptions.Default; сессия, созданная с 0, откатывается к 30000, а часы стартуют, как только подпись прошла, покрывая все дальнейшие запросы - Запросы: максимум 8 на сессию, счёт до попытки транспорта, так что мёртвый хост всё равно сжигает слот
- Байты: 1 МиБ на ответ и 4 МиБ суммарно, причём URL длиннее 2048 символов отвергаются до любого соединения
Бухгалтерия строже, чем кажется. Байты, полученные от неудачного ответа, всё равно идут в зачёт общей суммы, поэтому сервер, отвечающий 404 с большой страницей, не может высосать бюджет задаром. Чтение, пересекающее лимит на ответ, прерывает скачивание, а не передаёт обрезанное тело парсеру ASN.1, и HTTP 200 с пустым телом отвергается outright — иначе путь AIA проиндексировал бы Data[0] пустого массива. Проходят только простые URL http:// и https:// — без редиректов, куки, креденшелов и автоматического обнаружения прокси, — при этом HTTPS сохраняет обычные проверки сертификата и имени хоста. Дедупликация URL нарочно ограничена одним вызовом: следующая валидация должна уметь увидеть свежеопубликованную CRL. Бюджет — на вызов, а не на документ, и ValidatePadesTrust верифицирует каждую подпись и каждый timestamp-токен отдельно, так что худший случай растёт с числом подписей
Почему запрос WinHTTP по таймауту всё ещё может писать в вашу память?
Потому что возврат по таймауту не отменяет колбэки, уже летящие в воздухе. Транспорт Windows гоняет WinHTTP асинхронно и ждёт на событии оставшееся у сессии время, и когда та ждунка сдаётся, запрос всё ещё может завершить чтение и сигналить потом. Наведите асинхронное чтение на стековый буфер — и то позднее завершение пишет во фрейм, принадлежащий к тому времени какой-то посторонней функции. Лекарство — владение, а не тайминг: событие и 16-килобайтный буфер чтения живут в heap-записи с двумя ссылками, одна у вызывающего, другая освобождается только финальным callback'ом HANDLE_CLOSING, так что какая сторона ни финиширует последней — память освободит она
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // вызывающий + финальный callback HANDLE_CLOSING
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // асинхронные чтения приземляются сюда, не на стек
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// В статусном callback: HANDLE_CLOSING — последнее уведомление, которое WinHTTP
// шлёт для запроса, поэтому он сбрасывает вторую ссылку
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
Что libcurl обязан дать на FPC Unix
Асинхронный резолвер и thread-safe сборку, иначе онлайн-закачка остаётся выключенной. На FPC Unix транспорт идёт через libcurl — ту же зависимость, что стоит за libcurl-бэкендом timestamp для не-Windows целей, — и биндинг отвергает любую библиотеку, чья маска фич не содержит ни CURL_VERSION_ASYNCHDNS, ни CURL_VERSION_THREADSAFE. Причина в том, что CURLOPT_NOSIGNAL, который библиотеке внутри чужого процесса ставить обязательно, в паре с синхронным резолвером означает, что DNS-lookup может элементарно пережить таймаут. Вторая ловушка — завершение: curl_global_cleanup не ждёт асинхронные DNS-потоки, поэтому раз libcurl инициализирован, модуль остаётся смапленным до выхода процесса, вместо того чтобы пустить фоновый поток бегать по выгруженному коду. Когда любое из требований провалено, SslCapabilities.OnlineRetrieval равен False, а SslVerifyOptionsDiagnostics репортит psvdOnlineRetrievalIgnored вместо того, чтобы притворяться, будто сеть опрашивали
Что результат гарантирует и что нет
Валидный RevocationStatus от этого бэкенда означает, что нашлись, были сконфигурированы или скачаны актуальные CRL, покрывающие всю цепочку, и ни одна не перечислила находящийся в ней сертификат; и ничего больше. OCSP нет, поэтому CA, публикующий отзыв только через OCSP, оставляет результат unsupported, а сетевой сбой выглядит в точности как CA, не публикующий ничего. Заметьте также, что psvdNoCrlsConfigured описывает только CRL, сконфигурированные вами, так что при онлайн-закачке это подсказка, а не прогноз провала. Когда audit trail обязан быть воспроизводимым без сети, оставьте NetworkPolicy в умолчании ptnpOffline: fetch-сессия не создаётся, и бэкенд не открывает ни одного соединения, что совпадает с офлайновым контрактом стороны CryptoAPI, описанным в статье о офлайновых проверках revocation PDF-подписей на Windows
Код закачки, бюджеты и транспортные биндинги едут исходниками вместе с PDFium Delphi component, так что вы можете точно убедиться, какие URL валидация может дёргать и сколько качать, прежде чем включать ptnpOnline на сервере, обрабатывающем недоверенные документы