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

RFC 5280 и отозванные подписанты: проверка пути подписи PDF

Подписанный счёт сходится побайтно, его подписанта отозвали месяц назад, а загруженный вами CRL вовсе не содержит серийного номера: он отвечает за другой раздел. Валидация пути в HotPDF спроектирована под такие случаи: правила scope и якорей RFC 5280, часы оценки в UTC и метки времени RFC 3161, чьё время засчитывается только после того, как цепочка TSA признана доверенной

Часы оценки работают по UTC

Всё, что сравнивает валидатор пути, хранится в UTC. RFC 5280 §4.1.2.5 требует кодировать даты действия сертификата как UTCTime или GeneralizedTime по гринвичскому времени, CRL-поля thisUpdate и nextUpdate пользуются теми же кодировками, а OCSP-ответы RFC 6960 несут producedAt, thisUpdate и nextUpdate как GeneralizedTime в UTC. У Delphi-TDateTime нет часового пояса, а Now возвращает локальное настенное время, поэтому проверка в духе if Now > NotAfter смешивает двое часов, и компилятор не предупредит. Ошибка расходится по географии. Западнее Гринвича она срабатывает раньше времени. На нью-йоркском сервере в 09:00 по местному, когда мировые часы показывают 14:00Z, OCSP-ответ, выпущенный в 13:59Z, на пять часов в будущем для наивных часов — далеко за пределами пятиминутной поправки на рассинхрон вроде дефолтного OCSPAllowedClockSkewMinutes HotPDF, — и ответ отвергается как «ещё не действителен». В Шанхае знак меняется: в 22:00 по местному, то есть в 14:00Z, сертификат подписанта с notAfter в 20:00Z уже выглядит просроченным — на восемь часов раньше срока. На build agent, выставленном в UTC, ни того ни другого сбоя не видно — это дефолт большинства хостед CI-раннеров

VerifyLoadedSignatureWithTrust в HotPDF на Delphi оценивает THPDFCertificateValidationOptions.ValidationTime в UTC, избегая нью-йоркской и шанхайской ошибок часов, которые локальный Now создаёт против OCSP producedAt и времени notAfter сертификата
Сравнение локального Delphi-Now с UTC-временами действия отвергает свежие OCSP-ответы западнее Гринвича и преждевременно просрочивает сертификаты восточнее — двух сбоев, которых никогда не покажет build agent в UTC

HotPDF считает THPDFCertificateValidationOptions.ValidationTime временем UTC. Если оставить дефолтный ноль, временем оценки станет текущее UTC — если только момент подписания не поставит доверенная метка времени (об этом ниже), — а реально использованный момент возвращается в Info.CertificateValidation.EvaluationTime. VerifyLoadedSignatureWithTrust сначала прогоняет побайтовую проверку диапазона, стоящую за svValid, и лишь потом строит путь. Если вы сами фиксируете исторический момент — скажем, для аудитора, который спрашивает, была ли подпись счёта действительна на конец квартала, — конвертация на вашей совести. Справляется TTimeZone.Local.ToUniversalTime из System.DateUtils. Применяйте её там, где метка времени входит в вашу систему, а не у вызова валидации: в зонах с переходом на летнее время один локальный час каждую осень повторяется, а другой весной не существует вовсе, так что колонка в базе с локальными метками не сможет гарантировать обратимость каждого мгновения

uses
  System.SysUtils, System.DateUtils, System.IOUtils,
  HPDFDoc, HPDFSignVerify;

var
  Pdf: THotPDF;
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  ClosingLocal: TDateTime;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoices-2026-q1.pdf');
    Options := THPDFCertificateValidationOptions.Default;
    SetLength(Options.TrustAnchors, 1);
    Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
    Options.RevocationMode := rvmRequire;
    // Конец квартала, как его записал ERP, в локальной зоне сервера
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Сертификаты, CRL и OCSP-ответы несут время в UTC
    Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
    Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
    Writeln('Evaluated at (UTC): ',
      DateTimeToStr(Info.CertificateValidation.EvaluationTime));
    case Status of
      cvsTrusted:
        Writeln('Trusted via ',
          string(Info.CertificateValidation.TrustAnchorSubjectDN));
      cvsRevoked:
        Writeln('Revoked at (UTC): ',
          DateTimeToStr(Info.CertificateValidation.RevokedAt));
    else
      Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

Какие сертификаты CRL реально покрывает?

CRL отвечает только за сертификаты внутри своего scope, а scope объявлен в расширении Issuing Distribution Point из RFC 5280 §5.2.5. CA, режущий данные об отзыве на части, публикует несколько CRL, все подписаны одним ключом и все свежие, и отсутствие сертификата в одном из них ничего не значит, пока этот CRL не заявляет, что покрывает его. Поля, сужающие scope, таковы:

  • distributionPoint: CRL покрывает только сертификаты, чьё расширение CRL Distribution Points называет эту точку
  • onlyContainsUserCerts и onlyContainsCACerts: только конечные сертификаты или только сертификаты CA
  • onlySomeReasons: отзывы по подмножеству кодов причин, вроде keyCompromise
  • onlyContainsAttributeCerts: CRL о сертификатах атрибутов и ничего не говорит о сертификатах открытых ключей
  • indirectCRL: записи могут принадлежать издателям, отличным от подписанта CRL

Наивный поиск проверяет подпись CRL, смотрит thisUpdate и nextUpdate, ищет серийный номер и объявляет сертификат хорошим, если серийника нет. Последний шаг имеет силу только после проверки scope из RFC 5280 §6.3.3 (b)(2). CRL для раздела 7 никогда не перечислит сертификат из раздела 3. CRL, ограниченный сертификатами CA, никогда не упомянет конечного подписанта. На отзыв сертификата оба ответят «не найдено». HotPDF сверяется с базовым CRL для сертификата только когда CRL не ограничен ни сертификатами атрибутов, ни отдельными причинами, его ограничение user/CA соответствует сертификату, а каждая названная точка distribution point совпадает с одним из URI в собственном расширении CRL Distribution Points сертификата — сравнение без учёта регистра; точка, заданная именем относительно издателя CRL, не совпадает ни с чем. CRL с ограничением по причинам не опрашивается вовсе, так что перечисленные в нём отзывы тоже не в счёт. §6.3.3 допускает сбор полного покрытия из причинных разделов с отслеживанием маски причин, пока не учтена каждая причина, но для подписей на документах проще свидетельство в виде OCSP-ответа или полносценного CRL: добывайте одно из них. Когда свидетельств в scope не осталось, статус отзыва неизвестен, и при rvmRequire вызов вернёт cvsRevocationUnknown

Тест scope CRL в HotPDF для проверки отзыва подписей PDF на Delphi: поля Issuing Distribution Point решают, опрашивается ли базовый CRL, а без свидетельств в scope rvmRequire возвращает cvsRevocationUnknown по RFC 5280
Прежде чем искать в CRL серийный номер, его Issuing Distribution Point обязан покрывать сертификат; иначе отсутствие записи ничего не доказывает насчёт отзыва

Якоря доверия, замыкающие путь, должны быть CA

RFC 5280 §6.1.1 передаёт алгоритму валидации пути якорь доверия как простые сведения — имя издателя и открытый ключ, — и алгоритм никогда не спрашивает, может ли собственный сертификат якоря что-либо издавать; тест basicConstraints из §6.1.4 выполняется на промежуточных сертификатах пути. Для якорей-корневых CA из курируемого хранилища это нормально. Документооборот ломает допущение, потому что люди прикалывают конечные сертификаты: закупочная система, получающая контракты от трёх известных поставщиков, часто доверяет подписному сертификату каждого поставщика напрямую. Пиннинг работает для пути из одного сертификата, где подписант и якорь — один и тот же сертификат. Опасен путь из двух. Закрытый ключ поставщика может подписать свежий сертификат с любым subject — хоть с именем вашего финансового директора, — и валидатор, считающий каждый якорный ключ издающим, выстроит от этой подделки до прикрученного листа образцовый путь

HotPDF применяет издательские требования RFC 5280 к якорю всегда, когда путь длиннее одного сертификата. Тогда якорю нужны basicConstraints с cA=TRUE (§4.2.1.9), а если у него есть расширение keyUsage — взведённый бит keyCertSign (§4.2.1.3). Корень без расширения basicConstraints считается издателем, только будучи self-issued, с равными именами subject и issuer, — так продолжают работать старые корни версии 1, появившиеся до расширений. Пиннинг продолжает работать. Приколотый конечный сертификат по-прежнему валидирует собственные подписи, а сертификат, который он выдал, возвращается как cvsPathValidationFailed со строкой Issue, сообщающей, что якорь, издавший путь, не является certificate authority

Валидация пути сертификатов RFC 5280 в HotPDF на Delphi: приколотый конечный сертификат в TrustAnchors валидирует собственные подписи, но изданный им путь падает с cvsPathValidationFailed без basicConstraints cA=TRUE и keyCertSign
Приколотый подписной сертификат поставщика продолжает работать для его подписей, тогда как любой сертификат, подписанный его ключом, отвергается: якорь не является certificate authority

Какие метки времени вправе задать время оценки?

Только доверенные. Токен RFC 3161, встроенный в CMS, утверждает, что значение подписи существовало на момент genTime, и валидаторы судят сертификат подписанта именно в этот момент — так подпись переживает свой сертификат. Токен при этом — просто ещё одна CMS-подпись. Проверка его сертификатом, который он сам несёт, доказывает внутреннюю непротиворечивость и ничего о том, кто его выпустил: любой владелец просроченного или отозванного подписного ключа может поднять собственный сервис штемпелевания времени, отштемпелевать новую подпись прошлогодним genTime и протащить её назад, в период действия сертификата. RFC 3161 §2.3 требует, чтобы сертификат TSA нёс расширенное назначение ключа id-kp-timeStamping как единственное своё назначение, помеченное critical. HotPDF принимает Info.Timestamp.GenerationTime как время оценки только после того, как сертификат токена сойдётся с вашими якорями, по текущему времени UTC и для цели time-stamping. Метка времени документа, /SubFilter ETSI.RFC3161, — другой случай: там токен и есть проверяемая подпись, а его genTime действует на его собственный путь

Поскольку токен проверяется с вашими же опциями, корень TSA обязан лежать в том же списке TrustAnchors, что и корень подписанта, а при rvmRequire цепочке TSA нужны собственные свидетельства отзыва. Пропустите что-то из этого — оценка откатится к текущему времени. Другой предел — срок жизни сертификата TSA: токен судится в текущее время, поэтому с истечением этого сертификата уходит и оценка по моменту подписания, и архивы добавляют метку времени документа заранее — шаг, описанный в свидетельствах PAdES LTV и обновлении меток B-LTA. Код, держащий сырые байты CMS, может задать тот же вопрос через HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), а ValidateLoadedSignatureBatch применяет его к каждой подписи пакета. Функция ниже сообщает, по каким часам вынесен вердикт

// Ожидание: с одним SignerRoot подпись, чей сертификат истёк в 2025-м,
// оценивается по текущему UTC и не вернёт cvsTrusted, хотя её токен
// метки времени верифицируется; с добавленным TsaRoot тот же вызов
// оценивается по genTime
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // Цепочку TSA судят по тому же списку якорей, что и подписанта
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // время может дать доверенная метка времени
  Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
  if Info.Timestamp.Kind <> tskSignature then
    Clock := 'no signature timestamp, current UTC time'
  else if (Info.Timestamp.Status = svValid) and
    (Info.CertificateValidation.EvaluationTime =
     Info.Timestamp.GenerationTime) then
    Clock := 'trusted timestamp genTime'
  else
    Clock := 'timestamp not trusted, current UTC time';
  Result := Format('%s at %s (UTC), status %d', [Clock,
    DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;

Враждебный ввод до первого решения о доверии

Часть работы валидации идёт по данным, которыми атакующий распоряжается целиком, ещё до того, как хоть что-то признано доверенным. Недостающие промежуточные достаются через caIssuers-URL расширения Authority Information Access из RFC 5280 §4.2.2.1, и каждый добытый промежуточный может рекламировать URL собственного издателя — список работы растёт, пока вы его обходите. В Delphi это вычёркивает цикл for. Конечное значение for-оператора вычисляется один раз до старта цикла — URL, дописанные во время обхода, не посетятся никогда, и цепочка, которой не хватает двух звеньев, останется неполной. Перечитывайте Count на каждом проходе циклом while. В HotPDF MaxFetch у HPDFFetchAIAIntermediates ограничивает, сколько сертификатов скачает один обход, а MaxFetchedIntermediates ставит тот же потолок, когда FetchMissingIntermediates разрешает валидации пути добывать сама

Подсказка Retry-After — ещё одно значение, которое выбирает незнакомец. Её сервер был назван непроверенным сертификатом, и он может просить часовой паузы на каждый запрос. Ограничивайте. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS ставит этот потолок для загрузки свидетельств и AIA в HotPDF — 30 000 мс по умолчанию. Процедура ниже применяет оба правила для команд, которые обходят AIA сами — скажем, чтобы прогреть кэш промежуточных у офлайн-яруса подписания

uses
  System.SysUtils, System.Generics.Collections;

type
  // Ваш транспорт: скачивает одну цель caIssuers и возвращает
  // caIssuers-URL, которые рекламирует полученный сертификат
  TFetchIssuer = reference to function(const URL: string;
    out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;

function WalkCAIssuers(const SeedURLs: TArray<string>;
  const Fetch: TFetchIssuer; MaxFetch: Integer;
  MaxRetryAfterMS: Cardinal): Integer;
var
  Queue: TList<string>;
  Seen: TDictionary<string, Boolean>;
  NextURLs: TArray<string>;
  URL: string;
  I, Attempt: Integer;
  RetryAfter: Cardinal;
  Ok: Boolean;

  procedure Enqueue(const AURL: string);
  begin
    if not Seen.ContainsKey(LowerCase(AURL)) then
    begin
      Seen.Add(LowerCase(AURL), True);
      Queue.Add(AURL);
    end;
  end;

begin
  Result := 0;
  Queue := TList<string>.Create;
  Seen := TDictionary<string, Boolean>.Create;
  try
    for URL in SeedURLs do
      Enqueue(URL);
    I := 0;
    // Цикл for зафиксировал бы Queue.Count на входе и никогда не дошёл
    // бы до URL, которые дописывают скачанные сертификаты ниже
    while (I < Queue.Count) and (Result < MaxFetch) do
    begin
      Ok := False;
      for Attempt := 1 to 3 do
      begin
        NextURLs := nil;
        RetryAfter := 0;
        Ok := Fetch(Queue[I], NextURLs, RetryAfter);
        if Ok or (Attempt = 3) then
          Break;
        // Пауза — желание сервера, а сервер выбрал
        // непроверенный сертификат
        if RetryAfter > MaxRetryAfterMS then
          RetryAfter := MaxRetryAfterMS;
        Sleep(RetryAfter);
      end;
      Inc(I);
      Inc(Result);
      if Ok then
        for URL in NextURLs do
          Enqueue(URL);
    end;
  finally
    Seen.Free;
    Queue.Free;
  end;
end;

Проверка CMS-подписи сама идёт до валидации пути — строить путь для подписи, которая не верифицируется, пустая работа, — и пользуется открытым ключом сертификата, встроенного в подпись. Такой порядок в момент самой дорогой арифметики ставит у руля размер ключа, выбранный атакующим. Доверенного пока ничего. Стоимость RSA-верификации растёт и с длиной модуля, и с длиной экспоненты, поэтому сертификат с огромным модулем и столь же длинной экспонентой способен занять поток модульным возведением в степень на сервере, проверяющем загруженные файлы. HotPDF отвергает RSA-модуль выше 16 384 бит, отвергает экспоненту длиннее модуля, а выше 3 072 бит принимает только экспоненты не длиннее 64 бит. Реальные подписные ключи лежат между 2 048 и 4 096 бит с e = 65537 — 17-битной экспонентой, так что потолок их не касается; пол — отдельная конфигурируемая политика, AlgorithmPolicy.MinimumRSAKeyBits, по умолчанию 2 048

Та же подпись может быть cvsTrusted на genTime своей метки времени и провалиться на сегодняшней дате. Логируйте Info.CertificateValidation.EvaluationTime и TrustAnchorSubjectDN рядом с каждым вердиктом, который возвращает компонент HotPDF Delphi PDF, — и аудитор, читающий запись позже, увидит, по каким часам и на каком якоре держится ответ