Artigo Técnico

O que a RFC 5280 pede ao validador de caminho da assinatura

Uma fatura assinada verifica byte a byte, o signer dela foi revogado no mês passado, e a CRL que você baixou nunca lista o número de série: ela fala por outra partição. A validação de caminho no HotPDF é desenhada em volta de casos assim: regras de escopo e âncora do RFC 5280, um relógio de avaliação em UTC e timestamps RFC 3161 cuja hora só conta depois que a cadeia da TSA é confiável

O relógio de avaliação roda em UTC

Toda comparação que um validador de caminho faz fica armazenada em UTC. O RFC 5280 §4.1.2.5 exige que as datas de validade de certificado sejam codificadas como UTCTime ou GeneralizedTime no Greenwich Mean Time, CRLs usam as mesmas codificações em thisUpdate e nextUpdate, e respostas OCSP do RFC 6960 carregam producedAt, thisUpdate e nextUpdate como GeneralizedTime em UTC. O TDateTime do Delphi não tem fuso horário e o Now devolve hora de parede local, então uma checagem escrita como if Now > NotAfter mistura dois relógios sem aviso de compilador. O erro se divide por geografia. A oeste de Greenwich ele roda cedo. Num servidor de Nova York às 09:00 locais, quando o relógio mundial marca 14:00Z, uma resposta OCSP produzida às 13:59Z fica cinco horas no futuro do relógio ingênuo, muito fora de uma tolerância de skew de cinco minutos como o OCSPAllowedClockSkewMinutes default do HotPDF, e é recusada como ainda não válida. Em Xangai o sinal inverte: às 22:00 locais, que são 14:00Z, um certificado de assinatura cujo notAfter é 20:00Z já parece expirado, oito horas antes da hora. Nenhuma das falhas aparece em um build agent configurado em UTC — o default na maioria dos runners de CI hospedados

O VerifyLoadedSignatureWithTrust no Delphi com HotPDF avalia o THPDFCertificateValidationOptions.ValidationTime em UTC, evitando os erros de relógio de Nova York e Xangai que um Now local causa contra producedAt de OCSP e horários notAfter de certificado
comparar um Now local do Delphi com horários de validade em UTC recusa respostas OCSP frescas a oeste de Greenwich e expira certificados cedo a leste, duas falhas que um build agent em UTC nunca mostra

O HotPDF trata o THPDFCertificateValidationOptions.ValidationTime como UTC. Deixado no default dele, zero, o horário de avaliação vira a hora UTC corrente, a menos que um timestamp confiável forneça o horário de assinatura (mais sobre isso abaixo), e o instante de fato usado volta no Info.CertificateValidation.EvaluationTime. O VerifyLoadedSignatureWithTrust roda a verificação de byte range por trás do svValid antes de qualquer coisa isso, depois monta o caminho. Quando você fixa um momento histórico você mesmo, digamos para um auditor perguntando se a assinatura de uma fatura estava boa no fechamento do trimestre, a conversão é tarefa sua. O TTimeZone.Local.ToUniversalTime do System.DateUtils faz isso. Aplique-o onde o timestamp entra no sistema e não na chamada de validação: em fusos com daylight saving uma hora local se repete todo outono e outra nunca existe na primavera, então uma coluna de banco de dados de timestamps locais não consegue representar de volta todo instante

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;
    // Fechamento do trimestre como o ERP o registrou, no fuso local do servidor
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Certificados, CRLs e respostas OCSP carregam horários em 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;

Quais certificados uma CRL de fato cobre?

Uma CRL fala apenas pelos certificados dentro do escopo dela, e o escopo é declarado na extensão Issuing Distribution Point do RFC 5280 §5.2.5. Uma CA que particiona os dados de revogação publica várias CRLs, todas assinadas com a mesma chave e todas frescas, e a ausência de um certificado em uma delas não significa nada a menos que aquela CRL afirme cobri-lo. Os campos que estreitam o escopo são estes:

  • distributionPoint: a CRL cobre apenas certificados cuja extensão CRL Distribution Points nomeia esse ponto
  • onlyContainsUserCerts e onlyContainsCACerts: só certificados de entidade final, ou só certificados de CA
  • onlySomeReasons: revogações para um subconjunto de reason codes, como keyCompromise
  • onlyContainsAttributeCerts: a CRL trata de attribute certificates e não diz nada sobre certificados de chave pública
  • indirectCRL: as entradas podem pertencer a issuers outros que não o signer da CRL

A busca ingênua verifica a assinatura da CRL, confere thisUpdate e nextUpdate, procura o número de série e declara o certificado bom quando o número não aparece. Esse último passo só vale depois do teste de escopo do RFC 5280 §6.3.3 (b)(2). A CRL da partição 7 nunca lista um certificado da partição 3. Uma CRL restrita a certificados de CA nunca lista um signer de entidade final. As duas respondem "não encontrado" para um certificado revogado. O HotPDF consulta uma CRL base para um certificado só quando a CRL não está limitada nem a attribute certificates nem a alguns reasons, a restrição de user ou CA dela bate com o certificado, e qualquer distribution point que ela nomeia bate com um dos URIs na extensão CRL Distribution Points do próprio certificado, comparados sem distinção de maiúsculas — um ponto dado como nome relativo ao issuer da CRL não bate com nada. Uma CRL limitada por reasons não é consultada de forma alguma, então as revogações que ela lista também não contam. §6.3.3 até permite montar cobertura completa a partir de partições de reasons, acompanhando uma máscara de reasons até todo reason estar coberto, mas para assinaturas de documentos uma resposta OCSP ou uma CRL de escopo completo é evidência mais simples: colete uma dessas. Sem evidência em escopo restante, o status de revogação é unknown, e sob rvmRequire a chamada retorna cvsRevocationUnknown

Teste de escopo de CRL no Delphi com HotPDF para checagem de revogação de assinatura PDF: campos Issuing Distribution Point decidem se uma CRL base é consultada, e sem evidência em escopo o rvmRequire retorna cvsRevocationUnknown sob o RFC 5280
antes de uma CRL ser procurada pelo número de série, o Issuing Distribution Point dela precisa cobrir o certificado; caso contrário, uma entrada ausente não prova nada sobre revogação

Trust anchors que fecham um caminho precisam ser CAs

O RFC 5280 §6.1.1 entrega ao algoritmo de validação de caminho a trust anchor dele como informação pura, um nome de issuer e uma chave pública, e o algoritmo nunca pergunta se o certificado da própria âncora pode emitir algo; o teste de basicConstraints em §6.1.4 roda sobre os certificados intermediários do caminho. Isso funciona quando as âncoras são root CAs de uma store curada. Fluxos de documento quebram a premissa, porque as pessoas fixam certificados de entidade final: um sistema de compras que recebe contratos de três fornecedores conhecidos frequentemente confia direto no certificado de assinatura de cada fornecedor. Fixar funciona para um caminho de um certificado, onde signer e âncora são o mesmo certificado. O perigo é um caminho de dois certificados. A chave privada de um fornecedor pode assinar um certificado fresco carregando qualquer subject, o nome do seu CFO por exemplo, e um validador que trata toda chave de âncora como chave de emissão monta um caminho limpo dessa falsificação até a folha fixada

O HotPDF aplica os requisitos de issuer do RFC 5280 à âncora sempre que o caminho é mais longo que um certificado. A âncora então precisa de basicConstraints com cA=TRUE (§4.2.1.9), e se ela carrega uma extensão keyUsage, o bit keyCertSign precisa estar definido (§4.2.1.3). Uma root sem extensão basicConstraints conta como issuer só quando é self-issued, com subject e issuer iguais, o que mantém funcionando as roots antigas da versão 1 que antecedem as extensões. Fixar continua funcionando. Um certificado de entidade final fixado ainda valida as assinaturas dele próprias, enquanto um certificado que ele assinou volta cvsPathValidationFailed, com uma string Issue afirmando que a âncora que emitiu o caminho não é uma certificate authority

Validação de caminho de certificado RFC 5280 no Delphi com HotPDF: um certificado de entidade final fixado em TrustAnchors valida as próprias assinaturas dele, mas um caminho que ele emitiu falha com cvsPathValidationFailed sem basicConstraints cA=TRUE e keyCertSign
fixar o certificado de assinatura de um fornecedor continua funcionando para as assinaturas desse fornecedor, enquanto qualquer certificado que a chave dele assinar é rejeitado porque a âncora não é uma certificate authority

Quais timestamps podem definir o horário de avaliação?

Só os confiáveis. Um token RFC 3161 embutido no CMS afirma que o valor da assinatura existia no genTime, e validadores julgam o certificado do signer naquele momento — é assim que uma assinatura sobrevive ao certificado dela. O token também é só mais uma assinatura CMS. Verificá-lo contra o certificado que ele carrega prova consistência interna e nada sobre quem o produziu: qualquer um segurando uma chave de assinatura expirada ou revogada pode rodar o próprio serviço de time-stamping, carimbar uma assinatura nova com o genTime do ano passado e empurrá-la de volta para dentro do período de validade do certificado. O RFC 3161 §2.3 exige que o certificado da TSA carregue o extended key usage id-kp-timeStamping como único propósito, marcado critical. O HotPDF adota o Info.Timestamp.GenerationTime como horário de avaliação só depois que o certificado do token valida contra as suas âncoras, na hora UTC corrente, para o propósito de time-stamping. Um document timestamp, /SubFilter ETSI.RFC3161, é um caso diferente. Ali o token é a própria assinatura sob validação, e o genTime dele se aplica ao caminho dele próprio

Como o token é conferido com as suas próprias opções, a root da TSA precisa estar na mesma lista de TrustAnchors que a root do signer, e sob rvmRequire a cadeia da TSA precisa de evidência de revogação própria. Perder qualquer um dos dois e a avaliação recai para a hora corrente. O tempo de vida do certificado da TSA é o outro limite: o token é julgado na hora corrente, então quando esse certificado expira a avaliação no horário de assinatura vai embora com ele, e arquivos acrescentam um document timestamp antes desse ponto, a etapa coberta em evidência PAdES LTV e renovação de timestamps B-LTA. Código que segura bytes CMS puros pode fazer a mesma pergunta via HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), e o ValidateLoadedSignatureBatch aplica isso a toda assinatura de um lote. A função abaixo reporta em qual relógio um veredito foi tomado

// Esperado: com apenas o SignerRoot, uma assinatura cujo certificado expirou
// em 2025 é avaliada na hora UTC corrente e não volta
// cvsTrusted, embora o token de timestamp dela verifique; com o TsaRoot acrescentado,
// a mesma chamada é avaliada no 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;
  // A cadeia da TSA é julgada contra a mesma lista de âncoras que o signer
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // um timestamp confiável pode fornecer a hora
  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;

Entrada hostil antes da primeira decisão de confiança

Parte do trabalho de validação roda sobre dados que um atacante controla por completo, antes de qualquer coisa ter sido confiável. Intermediários ausentes são buscados pelas URLs caIssuers de Authority Information Access do RFC 5280 §4.2.2.1, e cada intermediário buscado pode anunciar por sua vez a URL do issuer dele, então a worklist cresce enquanto você a percorre. No Delphi isso elimina um loop for. O valor final de um for statement é avaliado uma vez, antes do loop começar — URLs anexadas durante o caminho nunca são visitadas, e uma cadeia faltando dois níveis fica incompleta. Releia o Count a cada passada com um loop while em vez disso. No HotPDF, o MaxFetch do HPDFFetchAIAIntermediates limita quantos certificados uma passada baixa, e o MaxFetchedIntermediates define o mesmo limite quando o FetchMissingIntermediates deixa a validação de caminho buscar por conta própria

A dica Retry-After é outro valor que um estranho escolhe. O servidor dela foi nomeado por um certificado não verificado, e ele pode pedir uma hora de pausa em cada request. Fixe um teto. O THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS define esse teto para as buscas de evidência e AIA do HotPDF, 30.000 ms por default. A rotina abaixo aplica as duas regras para times que rastejam a AIA por conta própria, digamos para pré-aquecer o cache de intermediários de uma camada de assinatura offline

uses
  System.SysUtils, System.Generics.Collections;

type
  // Seu transporte: baixa um alvo caIssuers e devolve as
  // URLs caIssuers anunciadas pelo certificado que ele recebeu
  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;
    // Um for loop fixaria o Queue.Count na entrada e jamais alcançaria as
    // URLs que os certificados buscados anexam abaixo
    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;
        // A espera é desejo do servidor, e um certificado não verificado
        // escolheu o servidor
        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;

A checagem de assinatura CMS em si roda antes da validação de caminho, porque construir um caminho para uma assinatura que não verifica é trabalho desperdiçado, e ela usa a chave pública do certificado embutido na assinatura. Essa ordem põe um tamanho de chave escolhido pelo atacante no comando no momento em que a aritmética mais cara roda. Nada é confiável ainda. O custo da verificação RSA cresce com o comprimento do modulus e o do exponent, então um certificado com um modulus enorme e um exponent igualmente longo pode manter uma thread ocupada em exponenciação modular num servidor que verifica arquivos enviados. O HotPDF recusa um modulus RSA acima de 16.384 bits, recusa um exponent mais longo que o modulus, e acima de 3.072 bits aceita apenas exponents de no máximo 64 bits. Chaves de assinatura reais ficam entre 2.048 e 4.096 bits com e = 65537, um exponent de 17 bits, então o teto nunca as toca; o piso é uma política separada e configurável, o AlgorithmPolicy.MinimumRSAKeyBits, cujo default é 2.048

A mesma assinatura pode estar cvsTrusted no genTime do timestamp dela e falhar na data de hoje. Registre Info.CertificateValidation.EvaluationTime e TrustAnchorSubjectDN ao lado de todo veredito que o componente Delphi PDF HotPDF retorna, e um auditor lendo o registro depois vê em qual relógio e em qual âncora a resposta se apoia