Технічна стаття

AIA і CRL через OpenSSL для підписів PDF у Delphi

PDFium VCL тепер може добудувати ланцюжок підпису PDF і перевірити відкликання через мережу на своєму OpenSSL backend: коли увімкнено OnlineRetrieval, ConfigureSslCmsVerifier встановлює верифікатор, який завантажує відсутні проміжні сертифікати з AIA caIssuers URL і CRL із точок розповсюдження CRL, у межах фіксованого бюджету часу, запитів і байтів на один виклик верифікації. Завантажені сертифікати — лише матеріал ланцюжка. Довіра досі приходить виключно з системного сховища і якорів, які ви налаштуєте

Розрив, який це закриває, вилазить уперше, коли ви валідуєте реальні PDF на Linux-сервері. Чимала частка підписувачів вбудовує в CMS лише власний leaf-сертифікат, тож OpenSSL не дотягується до кореня, TrustStatus повертається invalid, а перевірка відкликання взагалі не запускається, бо ланцюжок так і не став довіреним. До v3.121.0 OpenSSL backend, описаний у статті про верифікацію підписів PDF з OpenSSL у PDFium VCL, був строго офлайн, і OnlineRetrieval не мав на нього жодного впливу. Одне варто сказати одразу: сам рушій PDFium взагалі не робить CMS-верифікації, тож усе правило нижче живе в PAdES-шарі компонента і його OpenSSL-біндінгу, де його можна прочитати

У якому порядку OpenSSL backend верифікує, тягне і перевіряє?

Спершу цілісність, потім довіра, потім відкликання, і мережу торкають лише поміж кроків, які її потребують. VerifyCmsWithSsl перевіряє підпис CMS і signed attributes (RFC 5652) із придушеною оцінкою ланцюжка, і якщо це провалюється, повертається негайно, ще до того, як fetch-сесія взагалі існуватиме, тож документ із битими байтами не тригерить жодного вихідного запиту. Лише якщо ланцюжок тоді провалюється і OnlineRetrieval увімкнено, він іде за AIA-посиланнями і верифікує знову. Точки розповсюдження CRL тягнуть лише щойно ланцюжок став довіреним, бо CRL, що висить на недовереному шляху, нічого не доводить. Три вердикти лишаються роздільними впродовж усього: валідний підпис з неповним ланцюжком досі звітується як валідний підпис

Порядок VerifyCmsWithSsl у OpenSSL backend PDFium Component: перевірка підпису CMS іде з придушеною оцінкою ланцюжка, тож биті байти ніколи не торкаються мережі; RetrieveIntermediates іде за AIA caIssuers URL лише після провалу ланцюжка з увімкненим OnlineRetrieval, а RetrieveCrls тягне 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-anchor, будь-хто міг би підписати саморобним ключем, навести AIA на власний сервер і отримати зелений вердикт. Тому RetrieveIntermediates передає кожен розпарсений сертифікат у CMS_add1_cert, який кладе його в недовірку множину цієї однієї структури CMS, і OpenSSL досі мусить добудувати шлях від нього до якоря, який ви налаштували, чи який уже тримає системне сховище. Є й тихіша причина: аргумент сертифікатів у CMS_verify — не заміна наскрізь для сертифікатів, вбудованих у CMS, тож додавання в саму CMS — надійний шлях

Петля завантаження навмисно вузька. RetrieveIntermediates робить щонайбільше 4 кола, кожне збирає caIssuers URL з кожного сертифіката, який тепер у CMS, і зупиняється щойно коло нічого не додає або вичерпано бюджет часу. Відповідь мусить декодуватися через d2i_X509 як один DER-сертифікат, що з'їдає все тіло; хвостові байти відкидаються, а PKCS#7-пакет лише з сертифікатами, поданий з URL .p7c, пропускається, а не розпаковується. Метод доступу OCSP у тому самому розширенні AIA ігнорується, бо цей backend не розмовляє OCSP. На боці відкликання 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, лише недовірке
  // перевірити ланцюжок знову проти того самого сховища якорів
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 символів, яким відмовлено
Межі — константи, а не поради: байти з невалідної відповіді досі з'їдають бюджет, а оскільки кожен підпис і кожна мітка часу верифікуються окремо, найгірший випадок росте з кількістю підписів

Облік суворіший, ніж здається спершу. Байти, отримані з невалідної відповіді, досі рахуються в суму, тож сервер, що відповідає 404 великою сторінкою, не може з'їсти бюджет безкоштовно. Читання, що переходить ліміт на відповідь, перериває завантаження замість передачі обрізаного тіла парсеру ASN.1, а HTTP 200 із порожнім тілом відкидається одразу, бо шлях AIA інакше індексував би Data[0] порожнього масиву. Проходять лише прямі URL http:// і https://, без редиректів, кук, credential-ів чи автовиявлення проксі, тоді як HTTPS тримає свої звичайні перевірки сертифіката і hostname. Дедуплікація URL навмисно скоуплена на один виклик: наступна валідація мусить мати змогу побачити щойно опубліковану CRL. Бюджет також на виклик, не на документ, і ValidatePadesTrust верифікує кожен підпис і кожен токен мітки часу окремо, тож найгірший випадок росте з кількістю підписів

Чому запит WinHTTP з таймаутом досі може писати у вашу пам'ять?

Бо повернення за таймаутом не скасовує callback-и, уже в польоті. Windows-транспорт веде WinHTTP асинхронно і чекає на подію з рештою часу сесії, і коли те чекання здається, запит може досі завершити читання і сигналізувати після. Направте асинхронне читання на стековий буфер — і те пізнє завершення пише у фрейм, який на той час належить якійсь нерідній функції. Фікс — у володінні, а не в таймінгу: подія і 16-кілобайтовий буфер читання живуть у heap-записі з двома посиланнями, одне тримає викликач, а друге вивільняється лише фінальним callback-ом HANDLE_CLOSING, тож хто бік не фінішував останнім — той і звільняє пам'ять

Чому запит WinHTTP з таймаутом досі може писати в пам'ять: повернення за таймаутом лишає callback-и в польоті, тож 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

Асинхронний resolver і thread-safe збірка, інакше онлайн-завантаження лишається вимкненим. На FPC Unix транспорт іде через libcurl — ту саму залежність, що стоїть за libcurl backend міток часу для не-Windows цілей, — і біндінг відмовляє будь-якій бібліотеці, чия маска можливостей не має ані CURL_VERSION_ASYNCHDNS, ані CURL_VERSION_THREADSAFE. Причина в тому, що CURLOPT_NOSIGNAL, який бібліотека в чужому процесі мусить поставити, у парі з синхронним resolver-ом означає: DNS-запит може просто пережити таймаут. Друга пастка — завершення: curl_global_cleanup не чекає асинхронні DNS-потоки, тож щойно libcurl ініціалізовано, модуль лишається змапованим до виходу процесу, замість пускати фоновий потік у вивантажений код. Коли будь-яка з вимог провалюється, SslCapabilities.OnlineRetrieval — False, а SslVerifyOptionsDiagnostics звітує psvdOnlineRetrievalIgnored, замість прикидатися, що мережу консультували

Що результат гарантує, а що ні

Валідний RevocationStatus від цього backend означає, що були знайдені, налаштовані чи завантажені актуальні CRL, що покривають весь ланцюжок, і жодна не перелічила сертифікат з нього; і нічого більше. OCSP немає, тож CA, що публікує відкликання лише через OCSP, лишає результат unsupported, а мережевий збій виглядає точнісінько як CA, що не публікує нічого. Зауважте також, що psvdNoCrlsConfigured описує лише CRL, які налаштували ви, тож з онлайн-завантаженням це підказка, а не прогноз провалу. Коли журнал аудиту мусить бути відтворюваним без доступу до мережі, лишайте NetworkPolicy на його усталеному ptnpOffline: fetch-сесія не створюється і backend ніколи не відкриває з'єднання, що збігається з офлайн-контрактом на боці CryptoAPI, описаним у статті про офлайн перевірку відкликання підписів PDF на Windows

Код завантаження, бюджети і транспортні біндінги виходять як сирці разом із компонентом PDFium для Delphi, тож ви можете точно підтвердити, з якими URL валідація може зв'язуватися і скільки вона може завантажити, перш ніж увімкнете ptnpOnline на сервері, що обробляє недоврені документи