Artigo Técnico

Filtros crypt em PDF no Delphi: políticas StmF, StrF e EFF

O componente PDF Delphi HotPDF implementa o modelo de filtros de criptografia da ISO 32000-1 §7.6.5 como três políticas independentes, não como uma única switch: ConfigureCryptFilterDefaults atribui separadamente o filtro de strings /StrF, o filtro de streams /StmF e o filtro de arquivos incorporados /EFF, SetStreamCryptFilter faz override de um único stream e GetLoadedCryptFilterInfo informa o que um arquivo de entrada declara. A maioria dos bugs de interoperabilidade de PDF criptografado mora nos espaços entre esses três

É este o tipo de falha que traz as pessoas até esta camada. Uma equipe distribui um documento em que o conteúdo da página precisa continuar legível para uma ferramenta downstream, mas o payload anexado não, então define /EFF /StdCF e deixa /StmF /Identity. O Acrobat abre tudo bem. Um reader de terceiros conforme devolve o anexo como lixo cifrado, porque /EFF é uma política do lado do produtor sobre qual filtro se aplica a arquivos incorporados, e um reader geral ainda resolve um stream sem marca por meio de /StmF. A correção não é um valor diferente para /EFF. A correção é um filtro /Crypt explícito no próprio stream do arquivo incorporado

O que a camada de filtros crypt realmente controla

Filtros crypt ficam entre o algoritmo de criptografia e o object graph, e decidem quais objetos o algoritmo toca, não como ele funciona. O dicionário /CF dentro do dicionário de criptografia mapeia nomes para definições de filtro, cada uma com um método /CFM, um /Length opcional e um /AuthEvent. As três entradas de nível superior /StrF, /StmF e /EFF selecionam então qual desses filtros nomeados se aplica a strings, a streams sem filtro explícito e a arquivos incorporados. O HotPDF restringe deliberadamente o que seus handlers integrados vão gravar. ConfigureCryptFilterDefaults aceita apenas os nomes reservados para o handler ativo: o Standard security handler emite /StdCF ou /Identity, o handler de chave pública emite /DefaultCryptFilter ou /Identity, e qualquer outra coisa levanta EArgumentException no ponto da chamada. Filtros gravados por produtores externos sob outros nomes ainda são preservados nos caminhos de load, inspeção e rewrite de compatibilidade, então o HotPDF é conservador como writer e permissivo como reader. Duas proteções adicionais se aplicam: a chamada levanta EInvalidOpException depois que a serialização do documento começou e também quando o documento está em uma atualização incremental, porque a política de criptografia não pode mudar entre revisões do mesmo arquivo

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // strings criptografadas, streams de pagina em claro, anexos criptografados
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

Vale declarar uma restrição desde já, porque ela é verificada tarde e surpreende. Filtros crypt nomeados no HotPDF exigem criptografia do documento com aes128, aes256 ou aesgcm. Configure uma política de filtro sobre RC4 k40 ou k128 e a passagem de validação que roda quando a criptografia é ativada levanta um erro, em vez de promover silenciosamente o tipo de chave. É a mesma postura de design do restante do caminho de criptografia PDF AES-256 no Delphi: recusar a configuração ambígua em vez de adivinhar o que o chamador quis dizer

Por que a entrada /Length significa duas coisas diferentes?

Porque a especificação a define em duas unidades diferentes conforme o security handler, e o HotPDF precisa respeitar ambas. Em um dicionário de filtro crypt cujo /CFM é /V2, a entrada /Length é expressa em bytes no Standard security handler e em bits no handler de chave pública. O /Length do dicionário de criptografia que fica ao lado de /V (ISO 32000-1 §7.6.2) está sempre em bits. Leia um dicionário de filtro com /Length 16 e você tem uma chave de 128 bits em um arquivo com Standard handler e um arquivo rejeitado em um arquivo de chave pública. O HotPDF normaliza isso ao capturar a configuração carregada. Ele multiplica o /Length de um filtro /V2 por oito somente quando o arquivo não é criptografado com chave pública, recua para o /Length no nível do documento quando o filtro omite o seu próprio e armazena o resultado em THPDFCryptFilterInfo.KeyLengthBits. AESV2 é fixado em 128 bits e AESV3 e AESV4 em 256, pois esses métodos não têm tamanho de chave negociável. A parte estrita vem depois: apenas /V2 de 40 e 128 bits são aceitos. Um filtro que resolva para qualquer outro tamanho é reportado como indisponível e a operação falha, em vez de ser arredondado para 128 sob a teoria de que a maioria dos produtores quis dizer 128. Normalizar silenciosamente um tamanho de chave é o caminho para distribuir um arquivo que descriptografa na sua máquina e em nenhum outro lugar

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF e /StmF tem padrao Identity; /EFF tem padrao /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

O que /CFM /None garante, e como /Identity difere?

Os dois chegam ao mesmo resultado por caminhos diferentes, e confundi-los quebra as consultas. Um filtro nomeado cujo /CFM seja /None e um filtro nomeado que omita /CFM por completo significam ambos que esse filtro não faz criptografia nem descriptografia — o HotPDF mapeia a entrada ausente para None antes de resolver, então os dois chegam a hcfmNone com tamanho de chave registrado como zero. /Identity é diferente por natureza: é o nome reservado que ignora totalmente a consulta em /CF, então um documento pode referenciar /Identity sem defini-lo em lugar algum de /CF. Nomes PDF diferenciam maiúsculas de minúsculas, o que torna outro detalhe de implementação inegociável: nenhuma consulta de filtro crypt pode ser case-insensitive. O HotPDF resolve nomes do subdicionário /CF, a entrada /Length do filtro e a verificação /Type do stream por consultas case-sensitive. Um arquivo que define /stdcf enquanto /StmF aponta para /StdCF está malformado, e tratar os dois como a mesma chave transformaria um bug de autoria detectável em uma chave errada aplicada silenciosamente a todos os streams do documento

Fazendo /EFF valer nos streams de arquivos incorporados

Quando /EFF difere de /StmF, o stream do arquivo incorporado precisa de uma entrada /Crypt explícita no início de seu /Filter e de um dicionário /DecodeParms correspondente com /Name na mesma posição do array. O HotPDF calcula isso por stream no momento do save: detecta /Type /EmbeddedFile, herda o filtro de arquivo incorporado configurado e emite a marca explícita /Crypt somente quando esse nome herdado difere do default efetivo do stream. Quando /EFF e /StmF concordam, nenhuma marca é gravada, porque um reader resolveria o mesmo filtro de qualquer maneira. A posição no array importa tanto quanto o nome. Quando o HotPDF lê um stream de volta, ele procura a entrada /Crypt em /Filter, registra seu índice e então consulta esse mesmo índice no array /DecodeParms para encontrar /Name. Um /Crypt no índice 0 pareado com parâmetros no índice 1 resolve para /Identity, não para o seu filtro. É também por isso que o writer preenche o array de parâmetros com null quando o stream já tinha /Filter, mas não tinha /DecodeParms: as posições precisam permanecer alinhadas

Existe uma armadilha ainda mais séria. Se o /Filter ou /DecodeParms existente for um objeto indireto — algo comum em arquivos de geradores que compartilham um array de filtros entre muitos streams — inserir /Crypt no local alteraria um filter graph compartilhado e corromperia todos os outros streams que apontam para ele. O HotPDF resolve o objeto indireto e primeiro o clona como um objeto direto privado do stream, limpando os números de objeto e geração para que a raiz indireta original nunca seja incorporada ao novo array. Para um stream que já usava ASCIIHexDecode, o resultado serializado é /Filter [ /Crypt /ASCIIHexDecode ] com /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. A mesma disciplina posicional governa toda outra cadeia de filtros, inclusive as percorridas ao extrair imagens de um PDF carregado por meio de seus filtros de decode

// Editor ja contem um documento carregado, e ContentStream e um
// THPDFStreamObject cujo /Filter e um nome /ASCIIHexDecode indireto
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// Um nome vazio limpa o override e remove o /Crypt obsoleto
// junto com seus parametros de decode no proximo save
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Object streams herdam a política de /Encrypt do documento?

Não, e presumir que sim é uma maneira confiável de produzir lixo. Um object stream precisa seguir a política real de /StmF ou sua própria marca /Crypt explícita: a mera presença de um dicionário /Encrypt não torna todo contêiner /ObjStm um ciphertext. Um documento com /StmF /Identity tem object streams em claro mesmo que suas strings estejam completamente criptografadas, e um decoder que as descriptografe ainda alimenta a etapa de inflate com uma entrada que nunca foi saída de deflate

A consequência para os objetos membros é a parte que vale ler duas vezes. Pela ISO 32000-1 §7.5.7, strings dentro de um object stream criptografado já estão em claro quando o contêiner é descriptografado, então descriptografá-las de novo seria um double-decrypt. O HotPDF protege isso consultando se o contêiner de cada objeto tipo 2 estava criptografado e pulando o objeto quando estava, contabilizando os skips em XRefProbeDecryptObjStmSkips como evidência direta de que a proteção disparou. Quando o contêiner estava em claro, as strings membro nunca foram cobertas por nada, então o HotPDF materializa esses membros e aplica /StrF a cada um individualmente — com a chave, como a implementação realmente faz, pelo número e pela geração do objeto membro, não pelo número do objeto /ObjStm que o contém. Inverta isso em um arquivo com política mista e toda string em todo objeto comprimido vira ruído. As regras de nível de contêiner estão cobertas em mais detalhe nas notas sobre object streams PDF e atualizações incrementais

Onde o HotPDF se recusa a adivinhar

A semântica de filtros crypt não existe abaixo de /V 4, portanto o HotPDF rejeita qualquer override por stream nesse tipo de arquivo com um erro explícito, em vez de gravar uma marca /Crypt que nenhum reader conforme respeitaria. O mesmo vale no lado da leitura: um dicionário de criptografia com /V abaixo de 4 limpa os três nomes de filtro carregados, porque não há nada para reportar. Três limites adicionais são impostos deliberadamente:

  • Um filtro por stream diferente de Identity em um documento criptografado com chave pública é recusado, porque uma política específica do stream no handler de chave pública exige um envelope de recipient específico do stream que o HotPDF ainda não emite
  • Arquivos incorporados criptografados com chave pública cujo /EFF difere do /StmF efetivo são recusados pelo mesmo motivo, em vez de serem gravados em um formato que não descriptografa para ninguém
  • O caminho rápido de arquivo direto AES-256 só se aplica quando strings, streams e arquivos incorporados resolvem para o mesmo método de filtro crypt e nenhum objeto no arquivo carrega um /Crypt explícito; uma política mista ou metadados em claro força um fallback para o caminho completo do object graph

Nada disso é uma decisão de performance. São os pontos em que um palpite errado produz um PDF que abre em um viewer, falha em outro e não dá sinal algum ao desenvolvedor até um cliente relatar o problema. Uma recusa em ConfigureCryptFilterDefaults ou no save custa uma exceção; um arquivo incorporado com chave errada silenciosamente custa um ciclo de suporte. Se você cria software Delphi ou C++Builder que produz ou consome PDF criptografado — conteúdo de página seletivamente em claro com anexos criptografados, wrappers de payload criptografado de PDF 2.0 ou interoperabilidade com arquivos cujas políticas de filtros crypt você não escolheu — a API de filtros crypt descrita aqui está no atual componente PDF Delphi HotPDF, junto dos caminhos de criptografia, object streams e atualização incremental sobre os quais ela se apoia