Artigo Técnico

A assinatura que um PDF criptografado deixa em texto claro

Um contrato criptografado com AES e assinado em outra ferramenta carrega com um valor de assinatura que decodifica para bytes aleatórios, ou, sob AES-GCM, com um dicionário de assinatura que não descriptografa. Fora do /ID do trailer e do dicionário de criptografia, a ISO 32000-2 §7.6.2 deixa apenas o /Contents da assinatura sem criptografia, e o PDFlibPas o mantém em claro na leitura e na escrita enquanto /Reason e /M seguem criptografados

A exceção que a ISO 32000-2 acrescentou às regras de criptografia

A ISO 32000-2 §7.6.2 diz que a criptografia cobre toda string e todo stream do arquivo, com quatro exceções:

  • os valores da entrada /ID do trailer
  • strings dentro do dicionário /Encrypt
  • strings dentro de streams, que são criptografados como um todo
  • qualquer string hexadecimal que seja o valor de /Contents em um dicionário de assinatura

As três primeiras são antigas. A ISO 32000-1 §7.6.1 lista só essas três, então um descriptografador escrito contra o texto de 2008 não tem instrução nenhuma para pular algo em um dicionário de assinatura. As ferramentas de assinatura escreviam o valor em claro de qualquer jeito. O motivo é mecânico — o signer reserva um placeholder hex, escreve o arquivo inteiro, calcula o hash de tudo exceto o placeholder, monta o CMS e remenda o hex dele no vão. Esse digest cobre os bytes como eles estão no disco, ciphertext incluído. Criptografar o valor da assinatura não protegeria nada, e forçaria a etapa de remendo a conhecer a file key e a enfiar um IV mais padding em um placeholder dimensionado antes do hash existir. O resto do dicionário segue as regras normais:

Entrada do dicionário de assinaturaTipo PDFArmazenada criptografada?
/Contentsstring hexadecimalNão, isenta pela ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /Nametext stringSim
/Mdate stringSim
/ByteRangearray de inteirosNão, inteiros nunca são criptografados
/Type, /Filter, /SubFilternameNão, names nunca são criptografados

Um reader que perde a exceção roda a cifra sobre DER puro. O que sai depende do algoritmo. RC4 é uma stream cipher, então a saída mantém o comprimento e vira ruído; o parser do CMS vê um primeiro byte que não é mais 0x30 e reporta uma assinatura malformada. AES-CBC trata os primeiros 16 bytes como vetor de inicialização e o resto como ciphertext, o que termina em erro de padding ou um blob de lixo mais curto. AES-GCM, definido para PDF pela ISO/TS 32003, é o caso mais severo. Toda string criptografada carrega o nonce e a authentication tag próprios, e um valor em claro não passa na checagem da tag. Um descriptografador que trata uma tag reprovada como fatal para o dicionário inteiro perde então /Reason e /M junto com o valor da assinatura

Dicionário de assinatura de PDF criptografado no Delphi com PDFlibPas, com o /Contents hexadecimal deixado em claro sob a ISO 32000-2 §7.6.2 enquanto /Reason e /M seguem criptografados, mais os modos de falha de RC4, AES-CBC e AES-GCM
o /Contents da assinatura segue como DER legível em um PDF criptografado enquanto /Reason e /M são ciphertext, e descriptografar esse DER mesmo assim falha de um jeito diferente em cada cifra

Como distinguir o /Contents de uma assinatura de todo outro /Contents?

Bater na chave pelo nome é a primeira tentativa óbvia, e falha nos dois sentidos. Dicionários de annotation também têm uma entrada /Contents — uma text string com o comentário que o revisor digitou — e essa string precisa estar criptografada. Pular ela e todo post-it de um arquivo confidencial fica legível sem a senha. Bater em /Type /Sig quebra no outro sentido. A Tabela 255 da ISO 32000-2 deixa o /Type opcional em um dicionário de assinatura, com Sig como default, e o exige apenas para /DocTimeStamp, então um produtor conforme pode omiti-lo. O PDFlibPas portanto trata uma string /Contents como valor de assinatura só quando o mesmo dicionário carrega um array /ByteRange mais um /Type de Sig ou DocTimeStamp, ou nenhum /Type ao lado de um name /Filter. O /Filter é exigido em todo dicionário de assinatura e o /ByteRange em toda assinatura que pertence a um signature field; uma annotation não tem nenhum dos dois. A biblioteca roda o mesmo teste ao criptografar no save e ao descriptografar no load, então o /Contents sai de um load guardando os bytes que o produtor escreveu

Teste de criptografia no Delphi com PDFlibPas distinguindo o /Contents de assinatura do /Contents de annotation por /ByteRange, /Type Sig ou DocTimeStamp e /Filter antes de criptografar ou descriptografar a string
comentários de annotation seguem criptografados porque o PDFlibPas trata o /Contents como valor de assinatura apenas ao lado de um /ByteRange e um /Type correspondente, ou um name /Filter quando o /Type está ausente

Criptografe primeiro, depois assine com o PDFlibPas

Assine por último. O digest da assinatura cobre os bytes armazenados, então qualquer operação que reescreve o arquivo depois da assinatura quebra todas as assinaturas dele. Aplicar criptografia a um documento assinado re-criptografa cada string e cada stream; remover a criptografia os reescreve com a mesma profundidade. A sequência que funciona é criptografar, salvar e então assinar o arquivo criptografado. O processo de assinatura acrescenta uma atualização incremental cujas strings novas são criptografadas com a file key existente e cujo placeholder de /Contents segue em claro. Abra o arquivo no processo de assinatura com a owner password; uma user password basta apenas quando as permissões incluem o bit 9 da Tabela 22 da ISO 32000-2, que nomeia signature fields explicitamente. Encrypt e o processo de assinatura estão disponíveis em todas as edições do PDFlibPas

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // impressão, preencher campos (bit 9 cobre signature fields), cópia para acessibilidade, impressão em alta resolução
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6: file key usada diretamente, /ID não entra nela
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // criado invisivelmente se não existir
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // folga no placeholder para um signature time stamp acrescentado depois
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // sempre retorna 0
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

O strength 4 seleciona AES-256 revision 6, e essa escolha elimina dois modos de falha. Sob RC4 e AES-128, o Algorithm 1 (ISO 32000-1 §7.6.2) deriva uma chave separada para cada objeto a partir do número de objeto e do generation number dele. O Algorithm 2 (ISO 32000-1 §7.6.3.3, passo e) também alimenta o primeiro elemento do /ID na file key. Uma atualização de assinatura reescreve o dicionário AcroForm e o array /Annots da página, então cada objeto reescrito precisa manter o generation number original, e o trailer da atualização precisa carregar o mesmo primeiro elemento de /ID. Errar o generation e esse objeto para de descriptografar. Errar o /ID e nada descriptografa. A revision 6 usa a file key diretamente (ISO 32000-2 §7.6.3.3, Algorithm 1.A) e a recupera sem consultar o /ID (§7.6.4.3.3, Algorithm 2.A). O PDFlibPas mantém os generation numbers nos objetos reescritos em todos os strengths e deriva a chave do /ID como armazenado, mesmo quando um arquivo malformado traz /ID 7 0 R em vez do array direto que a Tabela 15 da ISO 32000-2 exige; um /ID [] vazio reprova a checagem de senha. O caminho senha-para-chave é traçado com mais profundidade junto com abrir PDFs criptografados com uma raw file key

Um editor de texto basta para ver o resultado. Um dicionário de assinatura fica descomprimido na atualização que o adicionou, porque o signer remenda /ByteRange e /Contents por offset de arquivo. Procure por /ByteRange e você cai nele. O hex de /Contents tipicamente abre com 3082, o header DER do CMS SignedData SEQUENCE, e termina em uma longa sequência de zeros onde o placeholder era maior que a assinatura. O /Reason é ciphertext que muda de uma rodada de assinatura para outra mesmo quando o texto não muda, porque o AES sorteia um IV fresco por string. Esperado com um qpdf recente: qpdf --password=owner-secret --show-object=14 signed.pdf imprime o /Reason descriptografado ao lado de um /Contents inalterado. Use um arquivo strength 4 para essa conferência. Formato esperado no arquivo bruto, com offsets e bytes variando:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

Quais assinaturas quebram quando um time stamp é remendado?

Todas as assinaturas acrescentadas depois da remendada. Um PAdES signature time stamp é um token RFC 3161 armazenado como atributo unsigned do signer (RFC 5652 §5.3, unsignedAttrs), então ele mora dentro do CMS no /Contents. Acrescentar um significa reescrever aquele hex no lugar. Em um arquivo criptografado essa reescrita funciona só porque o /Contents está em claro: sem re-criptografia, sem objeto novo — o token só precisa caber no espaço reservado na hora da assinatura. A assinatura dona do vão sobrevive porque o /ByteRange dela pula esses bytes. As assinaturas posteriores não ganham essa isenção. A ISO 32000-2 §12.8.1 faz o byte range de cada assinatura ir do header %PDF- até o fim da atualização incremental que a adicionou. Uma segunda assinatura, ou um document time stamp, portanto hasheia o /Contents da primeira assinatura junto com todo o resto. O vão pertence a uma assinatura só. Remende-o depois que um segundo signer assinou, e o digest da segunda assinatura não bate mais. O pedido do token em si faz parte de assinatura e validação PAdES a partir do Delphi; esta seção trata só de quais bytes se movem

O PDFlibPas checa a sobreposição antes de hashear qualquer coisa. O processo de signature time stamp lê o /ByteRange de todas as outras assinaturas do documento e compara cada par de spans com o vão de /Contents do alvo. Qualquer sobreposição interrompe a preparação com process result 17, e nenhum arquivo de saída é escrito. O processo pertence à edição Enterprise, que você instancia como TPDFlibEnterprise a partir da unit PDFlibEnterprise; as outras edições mantêm os métodos, que retornam 0 e põem o LastErrorCode em 900. Fique de olho no número 17 nos logs. Vindo do GetPAdESSignatureTimeStampProcessResult ele significa que uma assinatura posterior cobre esta; vindo do GetSignProcessResult significa que um nível de certificação ou um field lock proíbe assinar. Mapeie os dois separadamente. O helper abaixo usa SignField e OwnerPwd da listagem anterior

Ordenação de PAdES signature time stamp no Delphi com PDFlibPas: o /ByteRange de uma assinatura posterior cobrindo o vão de /Contents anterior faz o GetPAdESSignatureTimeStampProcessResult retornar 17 sem arquivo escrito
depois que o Approver assina, o byte range dele cobre o vão de /Contents do Author, e o PDFlibPas recusa o time stamp tardio do Author com process result 17 em vez de quebrar a assinatura posterior
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // valor hasheado, troca com a TSA falhou: veja GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // Esperado: 17. O byte range do Approver cobre o /Contents do Author no
    // step2.pdf, então o processo recusa e restamped.pdf nunca é escrito
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

Remende cada assinatura antes de o próximo signer tocar o arquivo. Isso custa uma ida e volta à TSA por signer e dá a cada assinatura um tempo confiável próprio. Um arquivo que chega com várias assinaturas já no lugar é outro caso: as anteriores não podem mais receber um signature time stamp. Acrescente um document time stamp em vez disso — uma assinatura /Type /DocTimeStamp com /SubFilter /ETSI.RFC3161 em uma revisão nova, cujo byte range cobre todas as assinaturas anteriores e prova que elas existiam naquele momento. O ApplyPAdESLongTermArchiveValidation acrescenta um depois de anexar o material de validação. Reserve espaço para um signature time stamp ao assinar, via SetSignProcessReserveContentsBytes como na primeira listagem. Um token que embute a cadeia de certificados da TSA facilmente passa de vários kilobytes, e um placeholder sem essa folga faz a ampliação posterior falhar com result 13

Descriptografar um arquivo assinado e o que o verificador hasheia

As faixas assinadas de um arquivo criptografado são ciphertext. Um verificador compara um digest do ciphertext armazenado com o digest dentro do CMS: o /ByteRange são inteiros, o /Contents está em claro, e a checagem de integridade não precisa de chave — só o nome do field e o /Reason precisam. O mesmo fato explica por que descriptografar um arquivo assinado e salvá-lo em claro invalida todas as assinaturas dele, embora nenhum pixel visível tenha mudado. Guarde o original criptografado e assinado como cópia de registro. Cross-reference streams também nunca são criptografados (ISO 32000-2 §7.5.8.2), então uma atualização escrita com xref stream mantém os offsets legíveis antes de qualquer chave ser derivada. O layout é descrito em object streams e cross-reference streams no Delphi. Quando uma faixa assinada é grande demais para caber na memória na comparação, o PDFlibPas reporta a checagem de integridade como unknown em vez de invalid, porque bytes nunca comparados não tiveram diferença demonstrada

O valor de assinatura de um arquivo criptografado segue gravável depois da assinatura até que outra assinatura ou um document time stamp o cubra. Ponha a etapa de time stamp dentro dessa janela, e a sequência criptografar-então-assinar da primeira listagem se transfere para arquivos multi-signer no PDF Library for Delphi sem mudanças