O componente PDF HotPDF para Delphi implementa o modelo de filtros crypt da ISO 32000-1 §7.6.5 como três políticas independentes, em vez de um único switch: ConfigureCryptFilterDefaults atribui separadamente o filtro de strings /StrF, o filtro de streams /StmF e o filtro de ficheiros incorporados /EFF, SetStreamCryptFilter substitui um único stream e GetLoadedCryptFilterInfo comunica o que um ficheiro recebido declara. A maioria dos bugs de interoperabilidade de PDFs encriptados vive nas lacunas entre esses três
Eis a falha que envia as pessoas para esta camada. Uma equipa entrega um documento em que o conteúdo da página tem de continuar legível para uma ferramenta a jusante, mas o payload anexado não, por isso define /EFF /StdCF e deixa /StmF /Identity. O Acrobat abre-o bem. Um leitor de terceiros conforme devolve o anexo como lixo cifrado, porque /EFF é uma política do lado do produtor sobre o filtro aplicável a ficheiros incorporados e um leitor geral continua a resolver um stream sem marca através de /StmF. A correção não é um valor de /EFF diferente. A correção é um filtro /Crypt explícito no próprio stream do ficheiro incorporado
O que controla realmente a camada de filtros crypt?
Os filtros crypt ficam entre o algoritmo de encriptação e o grafo de objetos e decidem quais objetos o algoritmo toca, não como funciona. O dicionário /CF dentro do dicionário de encriptação mapeia nomes para definições de filtros, 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 um filtro explícito e a ficheiros incorporados. O HotPDF restringe deliberadamente o que os seus handlers integrados podem escrever. ConfigureCryptFilterDefaults aceita apenas os nomes reservados para o handler ativo: o handler de segurança Standard emite /StdCF ou /Identity, o handler de chave pública emite /DefaultCryptFilter ou /Identity e qualquer outra coisa levanta EArgumentException no local da chamada. Os filtros escritos por produtores externos com outros nomes continuam preservados nos percursos de carregamento, inspeção e reescrita de compatibilidade, pelo que o HotPDF é conservador como writer e permissivo como reader. Aplicam-se ainda duas guardas: a chamada levanta EInvalidOpException depois de começar a serialização do documento e novamente se o documento estiver numa atualização incremental, porque a política de encriptação não pode mudar entre revisões do mesmo ficheiro
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 encriptadas, streams de páginas em texto simples, anexos encriptados
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 a pena declarar desde já uma restrição, porque é verificada tarde e surpreende algumas pessoas. Os filtros crypt nomeados no HotPDF exigem encriptação do documento aes128, aes256 ou aesgcm. Configure uma política de filtros sobre RC4 k40 ou k128 e a passagem de validação executada quando a encriptação é ativada levanta uma exceção em vez de promover silenciosamente o tipo de chave. É a mesma posição de desenho do resto do percurso de encriptação PDF AES-256 em Delphi: recusar a configuração ambígua em vez de adivinhar o que o chamador quis dizer
Porque é que a entrada /Length significa duas coisas diferentes?
Porque a especificação a define em duas unidades diferentes consoante o handler de segurança, e o HotPDF tem de respeitar ambas. Num dicionário de filtros crypt cujo /CFM é /V2, a entrada /Length é expressa em bytes no handler Standard e em bits no handler de chave pública. O /Length do dicionário de encriptação que fica ao lado de /V (ISO 32000-1 §7.6.2) está sempre em bits. Leia um dicionário de filtros com /Length 16 e terá uma chave de 128 bits num ficheiro com handler Standard e um ficheiro rejeitado num ficheiro com chave pública. O HotPDF normaliza isto quando captura a configuração carregada. Multiplica por oito o /Length de um filtro /V2 apenas quando o ficheiro não está encriptado com chave pública, recorre ao /Length ao nível do documento quando o filtro omite o seu próprio e guarda o resultado em THPDFCryptFilterInfo.KeyLengthBits. AESV2 fica fixado em 128 bits e AESV3 e AESV4 em 256, uma vez que esses métodos não têm um tamanho de chave negociável. A parte rígida vem depois: só são aceites /V2 de 40 e 128 bits. Um filtro que resolva para qualquer outro comprimento é comunicado como indisponível e a operação falha, em vez de ser arredondado para 128 com base na teoria de que a maioria dos produtores queria dizer 128. Normalizar silenciosamente o comprimento da chave é a forma de distribuir um ficheiro que desencripta na sua máquina e em mais lado nenhum
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 usam Identity por predefinição; /EFF usa /StmF por predefinição
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 garante /CFM /None e como difere /Identity?
Chegam ao mesmo resultado por percursos diferentes, e confundi-los quebra as pesquisas. Um filtro nomeado cujo /CFM é /None e um filtro nomeado que omite completamente /CFM significam ambos que esse filtro não realiza encriptação nem desencriptação — o HotPDF mapeia a entrada ausente para None antes de resolver, pelo que ambos acabam em hcfmNone com um comprimento de chave registado como zero. /Identity é diferente por natureza: é o nome reservado que ignora completamente a pesquisa em /CF, pelo que um documento pode referenciar /Identity sem o definir em lado algum de /CF. Os nomes PDF distinguem maiúsculas de minúsculas, o que torna outro detalhe de implementação inegociável: nenhuma pesquisa de filtro crypt pode ignorar maiúsculas e minúsculas. O HotPDF resolve nomes de subdicionários /CF, a entrada /Length do filtro e a verificação /Type do stream através de pesquisas de dicionário sensíveis a maiúsculas e minúsculas. Um ficheiro que defina /stdcf enquanto /StmF aponta para /StdCF está malformado, e tratar as duas chaves como iguais transformaria um erro de autoria detetável numa chave errada aplicada silenciosamente a todos os streams do documento
Fazer com que /EFF se aplique aos streams de ficheiros incorporados
Quando /EFF difere de /StmF, o stream do ficheiro incorporado precisa de uma entrada /Crypt explícita no início do seu /Filter e de um dicionário /DecodeParms correspondente com /Name na mesma posição do array. O HotPDF resolve isto por stream no momento de guardar: deteta /Type /EmbeddedFile, herda o filtro de ficheiros incorporados configurado e emite o marcador /Crypt explícito apenas quando esse nome herdado difere do default efetivo do stream. Quando /EFF e /StmF coincidem, não escreve nenhum marcador, porque um leitor resolveria o mesmo filtro de qualquer modo. A posição no array importa tanto como o nome. Quando o HotPDF lê um stream, percorre /Filter à procura da entrada /Crypt, regista o seu índice e procura depois esse mesmo índice no array /DecodeParms para encontrar o /Name. Um /Crypt no índice 0 emparelhado 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 um null quando o stream já tinha um /Filter mas não tinha /DecodeParms: as posições têm de continuar alinhadas
Há uma armadilha ainda mais séria por baixo. Se o /Filter ou o /DecodeParms existente for um objeto indireto — comum em ficheiros de geradores que partilham um único array de filtros por vários streams — inserir /Crypt no local mutaria um grafo de filtros partilhado e corromperia todos os outros streams que lhe apontassem. O HotPDF resolve o objeto indireto e clona-o primeiro para um objeto direto privado do stream, limpando os números de objeto e de geração para que a raiz indireta original nunca fique incorporada no 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 rege todas as outras cadeias de filtros, incluindo as que percorre ao extrair imagens de um PDF carregado através dos seus filtros de descodificação
// O Editor já contém um documento carregado e ContentStream é um
// THPDFStreamObject cujo /Filter é 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 a substituição e remove o /Crypt obsoleto
// juntamente com os seus parâmetros de descodificação na gravação seguinte
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');
Os object streams herdam a política /Encrypt do documento?
Não, e assumir que sim é uma forma segura de produzir lixo. Um object stream tem de seguir a política efetiva /StmF ou o seu próprio marcador /Crypt explícito: a simples presença de um dicionário /Encrypt não transforma todos os contentores /ObjStm em ciphertext. Um documento com /StmF /Identity tem object streams em texto simples mesmo que as suas strings estejam totalmente encriptadas, e um decoder que os desencripte na mesma entrega ao estágio inflate uma entrada que nunca foi saída deflate
A consequência para os objetos membros é a parte que vale a pena ler duas vezes. Segundo a ISO 32000-1 §7.5.7, as strings dentro de um object stream encriptado já estão em texto simples depois de o próprio contentor ser desencriptado, pelo que desencriptá-las novamente seria uma double-decrypt. O HotPDF protege-se disso consultando se o contentor de cada objeto de tipo 2 estava encriptado e saltando o objeto quando estava, contando os saltos em XRefProbeDecryptObjStmSkips como evidência direta de que a guarda disparou. Quando o contentor estava em texto simples, as strings membro nunca foram cobertas por nada, pelo que o HotPDF materializa esses membros e aplica /StrF a cada um individualmente — indexados, como a implementação efetivamente faz, pelo número e geração do objeto membro, não pelo número do objeto /ObjStm que o contém. Inverta isto num ficheiro com política mista e todas as strings de todos os objetos comprimidos descodificam para ruído. As regras ao nível do contentor são aprofundadas nas notas sobre object streams PDF e atualizações incrementais
Onde o HotPDF se recusa a adivinhar
A semântica dos filtros crypt não existe abaixo de /V 4, pelo que o HotPDF rejeita qualquer substituição por stream num ficheiro desses com um erro explícito, em vez de escrever um marcador /Crypt que nenhum leitor conforme respeitaria. O mesmo se aplica no lado da leitura: um dicionário de encriptação com /V inferior a 4 limpa os três nomes de filtros carregados, porque não há nada para comunicar. Há ainda três limites aplicados deliberadamente:
- Um filtro por stream diferente de
Identitynum documento encriptado com chave pública é recusado, porque uma política específica de stream sob o handler de chave pública precisa de um envelope de destinatário específico do stream que o HotPDF ainda não emite - Ficheiros incorporados encriptados com chave pública cujo
/EFFdifira do/StmFefetivo são recusados pela mesma razão, em vez de serem escritos numa forma que ninguém conseguiria desencriptar - O percurso rápido de ficheiro direto AES-256 aplica-se apenas quando strings, streams e ficheiros incorporados resolvem para o mesmo método de filtro crypt e nenhum objeto do ficheiro contém um
/Cryptexplícito; uma política mista ou metadados em texto simples força um fallback para o percurso completo do grafo de objetos
Nenhuma destas opções é uma decisão de performance. Elas marcam os pontos em que um palpite errado produz um PDF que abre num visualizador, falha noutro e não dá qualquer sinal ao programador até um cliente o comunicar. Uma recusa em ConfigureCryptFilterDefaults ou no momento de guardar custa uma exceção; um ficheiro incorporado com uma chave errada de forma silenciosa custa um ciclo de suporte. Se cria software Delphi ou C++Builder que produz ou consome PDFs encriptados — conteúdo de página seletivamente em texto simples com anexos encriptados, wrappers de payload encriptados de PDF 2.0 ou interoperabilidade com ficheiros cujas políticas de filtros crypt não escolheu — a API de filtros crypt descrita aqui é distribuída no atual componente PDF HotPDF para Delphi, juntamente com os percursos de encriptação, object streams e atualizações incrementais em que assenta