Artigo Técnico

Assinar PDFs com PAdES B-B no Delphi Utilizando o PDFium

O PDFium Component assina um PDF com uma assinatura digital PAdES B-B através do seu método SignPades: carrega o documento, calcula a hash do intervalo de bytes assinado, constrói uma estrutura CAdES CMS após o cálculo, e anexa a assinatura como uma atualização incremental. O backend criptográfico é exclusivo do Windows, pelo que deve proteger cada chamada com PadesCryptoAvailable antes de assinar

A situação é familiar. Um contrato em PDF chega à sua secretária, o departamento jurídico quer que seja assinado digitalmente antes de ser enviado, e recorre à mesma compilação do PDFium que já utiliza para renderizar e inspecionar documentos, apenas para descobrir que o PDFium não consegue gravar uma assinatura de todo. A sua API de assinatura é estritamente de apenas leitura. O PDFium Component preenche essa lacuna gerindo todo o pipeline de assinatura em Pascal, desde a função de hash até à injeção ao nível de bytes, e este artigo descreve esse pipeline de ponta a ponta

Por que o PDFium não consegue gravar uma assinatura digital?

O PDFium expõe as assinaturas como objetos de apenas leitura e não oferece recursos para criar uma. A família FPDFSignatureObj_* permite-lhe enumerar uma assinatura existente, ler a sua /Contents e inspecionar o seu /ByteRange, mas não existe contrapartida que crie um dicionário de assinaturas, reserve um espaço para /Contents ou grave um intervalo de bytes; a gravação incremental existe (FPDF_SaveAsCopy com FPDF_INCREMENTAL), mas não dispõe de suporte (hook) de assinatura. Qualquer componente que assine um PDF com base no PDFium deve, por isso, gerar cada byte da assinatura, razão pela qual o PDFium Component constrói esse mecanismo a partir de três units em Pascal puro. O FPC 3.2.2 inclui suporte a md5 e sha1, mas não disponibiliza qualquer SHA-2, e a API SHA-256 de System.Hash do Delphi não é compatível a nível de código-fonte com o FPC, pelo que a FPdfSha256 é uma implementação FIPS 180-4 autónoma que mantém todos os caminhos de código CMS sob um tipo TSHA256Digest sem diretivas de compilação condicionais. A FPdfAsn1 fornece o codificador e o leitor DER de que as estruturas CMS necessitam, e a FPdfCms monta a CAdES SignedData com base em ambos

Como assinar digitalmente um PDF no Delphi?

Carregue o documento e, em seguida, chame o método SignPades com a impressão digital (thumbprint) de um certificado. O PDFium Component resolve essa impressão digital contra o repositório de certificados "MY" do Utilizador Atual, obtém o certificado correspondente e a sua chave privada, e grava uma cópia assinada no caminho que indicar

Diagrama do pipeline de assinatura PAdES B-B em Delphi: o PDFium Component sonda o backend CNG do Windows, faz o hash do intervalo de bytes assinado, constrói o CMS CAdES e anexa uma atualização incremental
O PDFium Component detém todo o pipeline PAdES B-B, desde a sonda de plataforma pelo hash SHA-256 e construção do CMS até ao acrescento incremental
uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // o documento a assinar
    Pdf.Active := True;
    // Segundo argumento: impressão digital (thumbprint) SHA-1 de um certificado no repositório
    // "MY" do Utilizador Atual. Primeiro argumento: destino da cópia assinada.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

O PadesCryptoAvailable é a verificação a fazer primeiro, sempre. Em Windows devolve True e o backend crypt32/ncrypt está ativo; em qualquer outra plataforma devolve False e uma chamada de assinatura levantaria EPadesCrypto. Tratar esta salvaguarda como obrigatória evita que uma build de Linux ou macOS falhe em tempo de execução num caminho que ali não pode funcionar. A própria impressão digital é o hash SHA-1 do certificado, o mesmo valor que o gestor de certificados do Windows mostra no separador Detalhes, e identifica um signatário concreto sem alguma vez colocar material de chave no seu código-fonte

O que integra o CMS: atributos assinados e o RFC 5652

Uma assinatura de referência PAdES não é uma assinatura RSA simples sobre o ficheiro; trata-se de uma estrutura CAdES CMS SignedData que transporta um conjunto obrigatório de atributos assinados, e o método FPdfCms.BuildSignedData emite precisamente esse conjunto: content-type, message-digest e signing-certificate-v2, o atributo ESS que vincula a assinatura ao certificado do signatário por hash. Um detalhe específico inviabiliza quase todas as implementações manuais de CMS. A norma RFC 5652 §5.4 exige que o resumo dos atributos assinados seja calculado sobre a codificação DER SET OF, tag 0x31, ao passo que os mesmos atributos viajam dentro de SignerInfo sob a tag IMPLICIT [0], 0xA0. O PDFium Component codifica o conjunto de atributos uma vez, calcula o resumo do formato 0x31, e depois reescreve apenas o byte da tag inicial para 0xA0 para envio, permitindo que um único buffer sirva ambas as funções sem necessidade de uma segunda passagem sobre a estrutura

Diagrama da marcação de atributos assinados num CMS PAdES construído pelo PDFium Component em Delphi: a forma 0x31 SET OF passa a digest e, depois, apenas o byte inicial se torna 0xA0 dentro de SignerInfo
O RFC 5652 §5.4 faz o digest da codificação SET OF etiquetada 0x31, enquanto os mesmos bytes de atributo viajam dentro do SignerInfo sob a etiqueta IMPLICIT [0] 0xA0
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signatário no repositório MY
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // comprimento hexadecimal de /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

A sobrecarga com opções acrescenta os metadados do dicionário de assinatura definidos na ISO 32000-1 §12.8.1: Reason, Location, ContactInfo e Name, todos opcionais e todos escritos no dicionário de valor da assinatura. Há uma restrição em que é fácil tropeçar. Se definir CommitmentTypeOid para acrescentar um atributo assinado CAdES de indicação de tipo de compromisso, não defina também Reason; a ETSI EN 319 142-1 §6.3 proíbe transportar os dois, porque ambos exprimem a mesma intenção por meios diferentes

Como a ByteRange e o espaço /Contents se articulam?

Uma assinatura tem de cobrir todo o ficheiro, exceto os bytes que guardam a própria assinatura, e o PAdES resolve essa circularidade com um marcador de posição de largura fixa que a rotina SignPadesBytes gere com precisão. Reserva uma string hexadecimal para /Contents com o comprimento de ContentsSize bytes (16384 por padrão, confortavelmente superior a uma CMS SignedData típica), serializa a atualização incremental para localizar o offset exato do espaço, calcula depois a /ByteRange como dois intervalos que delimitam o espaço (tudo o que está antes do delimitador de abertura da string hexadecimal e tudo o que está após o delimitador de fecho). O SHA-256 processa apenas esses dois intervalos. O CMS finalizado é codificado em hexadecimal no espaço reservado, preenchido com zeros até à largura fixa, e a atualização de referências cruzadas é anexada. Como o comprimento é fixado à partida, o preenchimento do espaço não desloca qualquer byte a jusante, permitindo que o intervalo de bytes se mantenha válido; os bytes originais do documento são preservados na íntegra, pelo que uma assinatura anterior no mesmo ficheiro sobrevive intacta, exatamente como exige a gravação incremental da norma ISO 32000-1 §12.8.1

Diagrama da disposição ByteRange de um PDF assinado com o PDFium Component em Delphi: dois trechos com hash emolduram o slot hexadecimal Contents reservado e a atualização de referências cruzadas anexada
Os dois segmentos ByteRange enquadram a ranhura hexadecimal de largura fixa, pelo que preencher a assinatura nunca desloca um byte que o digest já contou

O backend Windows CNG e as suas limitações

O PDFium Component realiza assinaturas apenas em Windows, e esse limite é intencional. A FPdfCryptoWin vincula dinamicamente a crypt32.dll e a ncrypt.dll, sem adicionar dependências de DLL em tempo de compilação, e a cadeia de assinatura segue o padrão CNG: abre o repositório MY, encontra o certificado por hash, obtém o handle da sua chave privada através de CryptAcquireCertificatePrivateKey e chama a NCryptSignHash. São suportados RSA com PKCS#1 v1.5, RSA-PSS e ECDSA. O ECDSA necessita de uma correção de que os restantes não carecem, visto que a NCryptSignHash devolve o par em bruto IEEE P1363 de r e s, enquanto o CMS espera uma SEQUENCE DER ECDSA-Sig-Value, pelo que o backend volta a codificá-lo de acordo com o RFC 5480

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // sem motor de assinatura nesta plataforma

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

A consequência prática é que a chave privada deve residir no repositório de certificados do Windows. Um certificado contido num ficheiro PFX funciona apenas depois de o importar para o repositório do Utilizador Atual, ponto em que a sua impressão digital (thumbprint) passa a ser o valor que transmite à SignPades. Esta versão não disponibiliza caminhos para PKCS#11 ou HSM, nem backend para ficheiros de chaves de software, pelo que, quando a PadesCryptoAvailable devolve False, a assinatura não é possível nessa máquina

Onde termina o PAdES B-B

O PAdES B-B é o nível básico, o patamar inicial dos quatro níveis PAdES: prova quem assinou e que os bytes não foram alterados desde então, e nada mais do que isso. Uma assinatura B-B não transporta um carimbo de data/hora (timestamp) fidedigno, pelo que não pode comprovar quando ocorreu a assinatura, e não incorpora dados de revogação, pelo que um validador necessitará futuramente de obter a cadeia de certificados e o seu estado por si próprio. Essas lacunas são precisamente as que os níveis superiores resolvem. Quando necessitar de uma hora de assinatura que um auditor aceite, adicionar um carimbo de data/hora RFC 3161 e DSS para validação de longo prazo eleva a assinatura para o nível B-T ou superior; quando desejar ler uma assinatura finalizada e confirmar o nível atingido, inspecionar uma assinatura PDF e o seu nível PAdES é a ferramenta indicada; e antes de assinar qualquer documento, auditar um PDF em busca de riscos de segurança indica-lhe exatamente no que está prestes a colocar o seu nome

Os métodos SignPades apresentados aqui são fornecidos com o PDFium Component para Delphi e C++Builder, juntamente com a funcionalidade de inspeção de assinaturas de apenas leitura disponibilizada pelo PDFium por padrão