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

Верифікація PDF-підписів з OpenSSL у PDFium VCL

PDFium VCL розглядає верифікацію CMS як замінний бекенд за інтерфейсом IPdfCmsVerifier, тож PAdES-валідатор може бігти на Windows через CryptoAPI, на macOS через Keychain і будь-де, де є OpenSSL, через ConfigureSslCmsVerifier. Інтерфейс малий. Три поведінки OpenSSL під ним продукують впевнені неправильні відповіді, якщо реалізувати його наївно

Мотивація проста, щойно застосунок Delphi покидає Windows. Валідація підписів — одна з небагатьох областей, де платформений криптостек не є деталлю реалізації: він вирішує, які сертифікати довірені, які алгоритми існують і що означає відкликання. Зашити один — і код не портиться. Абстрагувати погано — і кожна платформа повідомляє відповідь іншої форми, яку викликаючий не може порівняти

Що абстракція справді мусить нести

Дві форми верифікації і три незалежні вердикти. PDF-підпис — від'єднаний: підписаний вміст — це два байтові діапазони з обох боків дірки /Contents, тож VerifyDetached бере два сегменти, а не один буфер. Токен часової мітки — приєднаний, несе власний вміст, тож VerifyAttached бере лише DER

Результат розпадається на три статуси, бо вони відповідають на три різні питання і можуть розходитися. SignatureStatus каже, чи байти були підписані ключем у сертифікаті підписувача. TrustStatus каже, чи той сертифікат зчіплюється з чимось, чому ви довіряєте. RevocationStatus каже, чи сертифікат був ще чинний у релевантний час. Документ з математично ідеальним підписом від сертифіката, про який ви ніколи не чули, — valid, untrusted і unknown, і зваляти те в один булевий — це спосіб, яким валідатори закінчують брехнею користувачам

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 не емітується: немає шляху витягування, з якого звітувати поступ

Діаграма трьох пасток верифікатора CMS на OpenSSL у PDFium VCL: спільний вмістовий BIO, прочитаний до кінця файлу, лишає другий прохід верифікації з нулем байтів, CMS_NO_SIGNER_CERT_VERIFY пригнічує оцінювання ланцюга, але не пошук підписувача, а перевірка CRL на порожньому сховищі відхиляє кожен ланцюг без жодного запуску перевірки відкликання
Кожна пастка дає впевнений неправильний вердикт: позиція потоку маскується під невдачу довіри, прапорець no-verify пригнічує менше, ніж обіцяє його назва, а перевірка відкликання, що ніколи не бігала, виглядає як перевірка, що знайшла проблеми

Це дизайн-позиція, яку варто боронити загалом. Валідатор, який не може перевірити відкликання, має це сказати. Звітування неперевіреного сертифіката як невідкликаного — найпоширеніший спосіб, яким інструменти валідації підписів вводять користувачів в оману, і це рівно той клас розгубленості, який досліджено в чому валідатори відхиляють підписи 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');

Прив'язка до бібліотеки, яку ви не можете приштифтувати

OpenSSL перейменував свої стекові аксесори між 1.0 і 1.1, тож та сама логічна функція має два можливі експортні імені залежно від збірки, яку хост має щастя мати. Прив'язка розв'язує нове ім'я спершу і відкатується на старіше, і записує відсутній символ лише коли жодне не розв'язалося. Це правильна форма для будь-якого динамічного підключення до бібліотеки, яку ви не постачаєте: надавайте актуальні імена, терпіть історичні і звітуйте лише справжню відсутність

SslMissingSymbols — те, що перетворює невдале завантаження на діагностовну подію. Непорожній результат на хості, який явно має встановлений libcrypto, означає, що встановлена версія старіша за API, під який цілиться ця збірка, — а це цілком інша розмова з підтримкою, ніж відсутня бібліотека. ConfigureSslLibraryPath покриває інший пошираний випадок — хост із кількома збірками OpenSSL, де та, що на шляху пошуку за дефолтом, не та, яку ви хочете

Вибір бекенда на платформу

Практична схема — вибирати при старті і записувати, який відповів. На Windows платформений бекенд інтегрується зі сховищами сертифікатів, які підприємство вже веде, — зазвичай саме це ви хочете. На macOS Keychain-бекенд пасує тому самому міркуванню і описаний у верифікації підписів з SecTrust на macOS. OpenSSL — портативний варіант, і він також правильний вибір, коли вам потрібна політика валідації, ідентична між платформами, а не та, що слідує сховищу довіри кожної платформи

Діаграма абстракції IPdfCmsVerifier у PDFium VCL, що несе VerifyDetached над двома байтовими діапазонами навколо дірки Contents і VerifyAttached для токенів часових міток, три незалежні вердикти SignatureStatus, TrustStatus і RevocationStatus, а також бекенди на платформу, обрані при старті через CryptoAPI, SecTrust чи ConfigureSslCmsVerifier
Інтерфейс несе дві форми верифікації і три вердикти, бо вони відповідають на різні питання і можуть розходитися, а встановлений бекенд записується поруч із кожним вердиктом, тож збережені результати можна відтворити

Що б ви ані встановили, логуйте PadesCmsVerificationBackendName поруч із кожним вердиктом, який записуєте. Збережений результат валідації без бекенда, що його продукував, не можна відтворити пізніше, бо три значення статусів означають ледь помітно різні речі залежно від того, який стек відповідав. Шар інспекції підписів поверх усього цього, включно з тим, як звітуються рівні PAdES, покритий у інспекції цифрових підписів PDF і рівнів PAdES

Усе це постачається сирцями з PDFium Delphi component, і тут це важить більше, ніж зазвичай: для валідатора підписів можливість прочитати, які саме прапорці бекенд виставляє і які перевірки пропускає, — не nice-to-have, а єдиний спосіб знати, що зелена галочка у вашому застосунку справді заявляє