Подписанный счёт сходится побайтно, его подписанта отозвали месяц назад, а загруженный вами 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-раннеров
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: только конечные сертификаты или только сертификаты CAonlySomeReasons: отзывы по подмножеству кодов причин, вроде keyCompromiseonlyContainsAttributeCerts: 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
Якоря доверия, замыкающие путь, должны быть 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 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, — и аудитор, читающий запись позже, увидит, по каким часам и на каком якоре держится ответ