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

Докачка AIA и CRL для PDF-подписей OpenSSL в Delphi

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, висящая на недоверенном пути, не доказывает ничего. Три вердикта остаются раздельными на всём протяжении: валидная подпись с неполной цепочкой по-прежнему репортится как валидная подпись

Порядок VerifyCmsWithSsl в OpenSSL-бэкенде PDFium Component: проверка подписи CMS идёт с подавленной оценкой цепочки, поэтому сломанные байты никогда не трогают сеть; RetrieveIntermediates идёт по URL AIA caIssuers только после провала цепочки при включённом OnlineRetrieval, а RetrieveCrls качает CRL точек дистрибуции в отдельное хранилище, когда цепочка стала доверенной
Целостность, затем доверие, затем revocation: сеть трогается только между шагами, которые её нуждаются, а 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 символов отвергаются до любого соединения
Жёсткие потолки одной TPdfCryptoFetchSession, которую шаги AIA и CRL вызова верификации делят в PDFium Component: UrlRetrievalTimeoutMs по умолчанию 15000 мс с откатом на 30000 при нуле, максимум 8 запросов на сессию, 1 МиБ на ответ и 4 МиБ суммарно с учётом и неудачных ответов, а URL длиннее 2048 символов отвергаются
Лимиты — константы, а не пожелания: байты неудачного ответа всё равно сжигают бюджет, и поскольку каждая подпись и каждый timestamp верифицируются отдельно, худший случай растёт вместе со счётом подписей

Бухгалтерия строже, чем кажется. Байты, полученные от неудачного ответа, всё равно идут в зачёт общей суммы, поэтому сервер, отвечающий 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, так что какая сторона ни финиширует последней — память освободит она

Почему запрос WinHTTP по таймауту всё ещё может писать в память: возврат по таймауту оставляет колбэки летящими, поэтому PDFium Component наводит асинхронное чтение на heap-запись THttpState, чей 16-килобайтный буфер и две ссылки — одна у вызывающего, другая освобождается финальным callback'ом HANDLE_CLOSING — освобождаются, только когда финиширует последняя сторона
Позднее завершение может доделать чтение после того, как ваша ждунка сдалась; heap-владение с двумя ссылками означает, что та запись приземлится в ещё живую память
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 на сервере, обрабатывающем недоверенные документы