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

OpenSSL AIA и CRL извличане за PDF подписи в Delphi

PDFium VCL вече може да завърши веригата на PDF подпис и да провери revocation по мрежата на своя OpenSSL backend: когато OnlineRetrieval е включен, ConfigureSslCmsVerifier инсталира верификатор, който сваля липсващи междинни сертификати от AIA caIssuers URL-ите и CRL от CRL distribution points, вътре в фиксиран бюджет за време, заявки и байтове на всяко извикване за верификация. Свалените сертификати са винаги само материал за веригата. Trust продължава да идва изключително от системния store и котвите, които сте настроили

Дупката, която това затваря, се показва при първата валидация на истински PDF на Linux сървър. Голяма част от подписващите вграждат в CMS само собствения си leaf сертификат, така че OpenSSL не стига до root, TrustStatus се връща invalid, а revocation изобщо не тръгва, защото веригата никога не става доверена. Преди v3.121.0 OpenSSL backend-ът, описан в верификацията на PDF подписи с OpenSSL в PDFium VCL, беше изцяло офлайн и OnlineRetrieval нямаше никакъв ефект върху него. Едно нещо си заслужава да се каже отначало: самият PDFium engine изобщо не прави CMS верификация, така че всяко правило по-долу живее в PAdES слоя на компонента и в OpenSSL обвързката му, където можете да го прочетете

В какъв ред OpenSSL backend-ът верифицира, издирва и проверява?

Първо integrity, после trust, после revocation, а мрежата се пипа само между стъпките, които имат нужда от нея. VerifyCmsWithSsl проверява CMS подписа и signed attributes (RFC 5652) с потисната оценка на веригата и ако това провали, връща веднага — преди изобщо да съществува fetch сесия — така че документ с разбити байтове не задейства нито един изходящ запрос. Само ако веригата после се провали и OnlineRetrieval е включен, той следва AIA връзките и верифицира отново. CRL distribution points се теглят само щом веригата стане доверена, защото CRL, висящ на недоверен път, не доказва нищо. Трите присъди си остават отделни през цялото време: валиден подпис с непълна верига все пак се докладва като валиден подпис

Ред на VerifyCmsWithSsl в OpenSSL backend-а на PDFium Component: проверката на CMS подписа върви с потисната оценка на веригата, така че разбити байтове никога не пипат мрежата; RetrieveIntermediates следва AIA caIssuers URL-ите само след провал на веригата с включен OnlineRetrieval, а RetrieveCrls тегли distribution point CRL-и в отделен store, щом веригата стане доверена
Integrity, после trust, после revocation: мрежата се пипа само между стъпките, които имат нужда от нея, а CRL, висящ на недоверен път, не доказва нищо
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; единственото допълнително trust
  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-anchor store, всеки би могъл да подпише с направен от себе си ключ, да насочи AIA към свой сървър и да получи зелена присъда. Затова RetrieveIntermediates подава всеки парснат сертификат на CMS_add1_cert, който го поставя в недовереното множество на тази една CMS структура, а OpenSSL пак трябва да изгради път от него към котва, която сте настроили, или която системният store вече държи. Има и по-тиха причина: аргументът за сертификати на CMS_verify не е заместващ еквивалент на вградените в CMS сертификати, така че добавянето в самия CMS е надеждният път

Цикълът на издирване е нарочно тесен. RetrieveIntermediates върви най-много 4 рунда, всеки от които събира caIssuers URL-и от всеки сертификат, вече намиращ се в CMS, и спира щом рунд не добави нищо или бюджетът за време изтече. Отговор трябва да се декодира с d2i_X509 като един DER сертификат, изяждащ цялото тяло; байтове в края се отхвърлят, а PKCS#7 пакет само с сертификати, сервирани от .p7c URL, се прескача вместо да се разопакова. OCSP access методът в същото AIA разширение се игнорира, тъй като този backend не говори OCSP. От страната на revocation RetrieveCrls чете само fullName URI-ите на всеки DistributionPoint (RFC 5280 §4.2.1.13) от CMS сертификатите и настроените котви, а свалените CRL отиват във втори, независим X509_STORE с full-chain 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
  // верифицирай веригата пак срещу същия anchor store
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// втори, отделен store: настроените CRL плюс свалените
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Какво струва едно извикване за верификация най-много?

Фиксиран таван, наложен от един TPdfCryptoFetchSession, който AIA и CRL стъпките на едно извикване за верификация споделят. Лимитите са константи в FPdfCryptoHttp, не пожелания:

  • Време: UrlRetrievalTimeoutMs, което по подразбиране е 15000 и в TPdfCmsVerifyOptions.Default, и в TPadesTrustValidationOptions.Default; сесия, създадена с 0, пада обратно на 30000, а часовникът тръгва щом подписът мине и покрива всеки по-късен запрос
  • Заявки: най-много 8 на сесия, броени преди опита за транспорт, така че мъртъв хост пак изяжда слот
  • Байтове: 1 MiB на отговор и 4 MiB общо, а URL по-дълги от 2048 символа се отказват преди каквото и да е свързване
Твърдите тавани на една TPdfCryptoFetchSession, споделяна от AIA и CRL стъпките на извикване за верификация в PDFium Component: UrlRetrievalTimeoutMs по подразбиране е 15000 ms с нулев fallback 30000, най-много 8 заявки на сесия, 1 MiB на отговор и 4 MiB общо с провалени отговори, които пак се броят, и URL над 2048 символа, които се отказват
Лимитите са константи, не пожелания: байтовете от провален отговор пак изтощават бюджета, а понеже всеки подпис и всеки timestamp се верифицират поотделно, най-лошият случай расте с броя на подписите

Сметките са по-строги, отколкото изглеждат на пръв поглед. Байтовете, получени от провален отговор, пак се броят в общата сума, така че сървър, отговарящ на 404 с голяма страница, не може да изтощи бюджета безплатно. Четенето, което прекоси лимита на отговор, прекъсва свалянето вместо да предаде отрязано тяло на ASN.1 parser-а, а HTTP 200 с празно тяло се отхвърля изцяло, защото AIA пътят иначе би индексирал Data[0] на празен масив. Минават само чисти http:// и https:// URL-и, без redirects, cookies, credentials или автоматично откриване на proxy, докато HTTPS си пази нормалните проверки за сертификат и hostname. Дедупликацията на URL е нарочно ограничена до едно извикване: следващата валидация трябва да може да види публикуван току-що CRL. Бюджетът е също на извикване, не на документ, а ValidatePadesTrust верифицира всеки подпис и всеки timestamp token поотделно, така че най-лошият случай расте с броя на подписите

Защо WinHTTP запрос, изтекъл по време, все още може да пише в паметта ви?

Защото връщането при изтекло време не отменя callback-ите, вече в полет. Windows транспортът задвижва WinHTTP асинхронно и чака на event с оставащото време на сесията, а когато това чакане се откаже, запросът пак може да завърши четене и да сигнализира после. Насочите ли асинхронното четене към стеков буфер, това късно завършване пише във frame, който дотогава принадлежи на някаква съвсем друга функция. Fix-ът е собственост, не тайминг: event-ът и 16 KB буферът за четене живеят в heap запис с две референции — една държана от извикващия и една освободена само от финалния callback HANDLE_CLOSING — така че коя страна свърши последна, тя освобождава паметта

Защо WinHTTP запрос с изтекло време все още може да пише в паметта: връщането при изтекло време оставя callback-и в полет, така че PDFium Component насочва асинхронното четене към heap заделен запис THttpState, чийто 16 KB буфер и две референции — една държана от извикващия и една освободена от финалния HANDLE_CLOSING callback — се освобождават едва когато последната страна приключи
Късно завършване може да приключи четенето си, след като вашето чакане се е отказало; heap собствеността с две референции значи, че този запис каца в памет, която все още е жива
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // извикващ + финален HANDLE_CLOSING callback
    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;

// В status callback-а: HANDLE_CLOSING е последното уведомление, което WinHTTP
// изпраща за запроса, така че то пуска втората референция
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

Какво трябва да даде libcurl на FPC Unix

Асинхронен resolver и thread-safe build, или online издирването остава изключено. На FPC Unix транспортът минава през libcurl — същата зависимост зад libcurl timestamp backend-а за не-Windows цели — а обвързката отказва всяка библиотека, чиято feature маска е без CURL_VERSION_ASYNCHDNS или CURL_VERSION_THREADSAFE. Причината е, че CURLOPT_NOSIGNAL, което библиотека в чужд процес задължително вдига, в комбинация със синхронен resolver значи, че DNS lookup просто може да надживее timeout-а. Вторият капан е shutdown: curl_global_cleanup не чака асинхронните DNS нишки, така че щом libcurl бъде инициализиран, модулът остава мапнат до края на процеса, вместо да позволи background нишка да влезе в разтоварен код. Когато което и да е изискване се провали, SslCapabilities.OnlineRetrieval е False, а SslVerifyOptionsDiagnostics докладва psvdOnlineRetrievalIgnored, вместо да се преструва, че мрежата е била питана

Какво резултатът гарантира и какво не

Валиден RevocationStatus от този backend значи, че са намерени, настроени или свалени текущи CRL, покриващи цялата верига, и нито един не изброява сертификат от нея; нищо повече. Няма OCSP, така че CA, публикуващ revocation само през OCSP, оставя резултата unsupported, а мрежов провал изглежда точно като CA, който не публикува нищо. Обърнете внимание също, че psvdNoCrlsConfigured описва само CRL, които вие сте настроили, така че при online издирване той е намек, не прогноза за провал. Когато audit trail трябва да е възпроизводим без мрежов достъп, оставете NetworkPolicy на default-а му ptnpOffline: не се създава fetch сесия и backend-ът никога не отваря връзка — същият офлайн договор като от страната на CryptoAPI, описан в офлайн проверките за revocation на PDF подписи в Windows

Кодът за издирване, бюджетите и транспортните обвързки се доставят като source с PDFium Delphi компонента, така че можете да проверите точно кои URL може да контактува една валидация и колко може да свали, преди да включите ptnpOnline на сървър, обработващ недоверени документи