Artigo Técnico

SASLprep AES-256 Senhas de PDF em Delphi com PDFlibPas

Um PDF criptografado com AES-256 e uma senha não ASCII abre no programa que o escreveu e em nenhum outro lugar. A causa é quase sempre um passo de preparação ausente: a ISO 32000-2 §7.6.4.3.3 exige que a senha seja processada com o perfil SASLprep do stringprep antes de ser codificada em UTF-8 e hasheada. PDFlibPas, a biblioteca PDF para Delphi e C++Builder, executa essa preparação dentro de Encrypt, EncryptFile e DecryptFile

Esta não é a história da senha errada e não é a história dos bits de permissão. Se seus usuários estão digitando uma senha que você nunca emitiu, o mecanismo de nova tentativa em o artigo sobre repetição de senhas de PDF criptografado é o que você quer, e se você está tentando descobrir o que um arquivo existente realmente aplica, a auditoria de criptografia e permissões cobre esse terreno. Este é mais estreito e mais estranho: a senha está correta, o usuário a digitou corretamente, e o arquivo ainda se recusa a abrir em outro lugar

Por que uma senha não ASCII abre em um leitor mas não em outro?

Porque os dois programas hasheiam sequências de bytes diferentes a partir das mesmas teclas digitadas. A derivação de chave de revisão 6 na ISO 32000-2 §7.6.4.3.3 toma a senha como bytes UTF-8, trunca para 127 bytes, anexa um salt, e executa o hash reforçado; o resultado é verificado contra as entradas /U e /O no dicionário de criptografia. Nada nessa cadeia é impreciso. Um único byte diferente em qualquer ponto da entrada produz um digest completamente diferente, a validação falha, e o leitor tem exatamente uma coisa a dizer: senha incorreta

Os bytes divergem porque o Unicode oferece várias formas de digitar o que parece ser a mesma senha. Uma senha em chinês pode chegar como caracteres precompostos de um método de entrada e como formas de compatibilidade de outro. Uma senha em alemão ou francês copiada de um processador de texto pode carregar um ESPAÇO INQUEBRÁVEL (U+00A0) onde o usuário acredita haver um espaço comum, ou um HÍFEN SUAVE (U+00AD) que renderiza como nada. O SASLprep existe para colapsar tudo isso em uma forma canônica única antes que alguém hasheie qualquer coisa, de modo que toda implementação conforme derive a mesma chave da mesma intenção

O que o SASLprep realmente muda em uma senha?

A RFC 4013 define SASLprep como um perfil do framework stringprep na RFC 3454, e são quatro passos ordenados em vez de uma única transformação. Mapeamento vem primeiro: a tabela C.1.2 da RFC 3454 (espaços não ASCII) é mapeada para U+0020, e a tabela B.1 (caracteres comumente mapeados para nada) é excluída completamente. Normalização para Unicode NFKC segue, que é o passo que dobra caracteres de compatibilidade e sequências combinantes. Depois a verificação de saída proibida rejeita qualquer coisa nas tabelas C.2.1 até C.9. Por fim a regra bidirecional da seção 6 da RFC 3454 é aplicada à string normalizada

PDFlibPas implementa o perfil inteiro na unit PDFlibSASLprep, que expõe um único ponto de entrada. PLSASLprepPassword recebe a senha bruta, escreve a forma preparada em um parâmetro var, e retorna False quando a senha deve ser recusada. A função é deliberadamente total no caminho feliz: uma senha somente ASCII volta byte a byte idêntica, então nada muda para implantações existentes

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

A ambiguidade U+200B que as tabelas não resolvem

Um código de ponto cai em duas tabelas da RFC 3454 ao mesmo tempo, e as duas tabelas discordam. ESPAÇO DE LARGURA ZERO (U+200B) cai dentro do intervalo C.1.2 U+2000 a U+200B, onde a regra diz para mapeá-lo para U+0020, e também cai dentro do intervalo B.1 U+200B a U+200D, onde a regra diz para excluí-lo. Leia o passo de mapeamento em qualquer ordem e você obtém bytes diferentes da mesma senha: a+U+200B+b se prepara para a b sob C.1.2 e para ab sob B.1. A RFC 4013 nomeia as duas tabelas e não diz qual vence, então esta é uma ambiguidade genuína na especificação, não um erro de leitura. PDFlibPas testa a associação com C.1.2 primeiro e portanto mapeia U+200B para um espaço, que é o comportamento no qual outras implementações amplamente implantadas de stringprep se estabeleceram; corresponder a elas é a única coisa que importa aqui, porque o objetivo é concordância de bytes com qualquer leitor que o cliente esteja usando

Lendo arquivos antigos: preparado primeiro, bruto depois

A correção cria seu próprio problema de compatibilidade. Todo arquivo AES-256 escrito antes da mudança hasheava a senha UTF-8 bruta, então tornar o leitor estritamente conforme trancaria os clientes para fora de seus próprios arquivos. PDFlibPas resolve isso no lado da leitura tentando dois candidatos em ordem. TPDFDocument.SetPassword constrói uma lista de candidatos que começa com a forma preparada e recorre à forma bruta, e só adiciona a entrada preparada quando o documento é de fato AES-256 e as duas formas diferem. Para uma senha ASCII as formas são idênticas, a lista contém uma entrada, e o custo de todo o mecanismo é uma única comparação de string. DecryptFile faz a mesma coisa em seu caminho direto de reescrita AES-256, chamando PLDirectDecryptFileAES256 com a senha preparada primeiro

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

O fallback carrega uma proteção que vale a pena copiar. A segunda tentativa em DecryptFile só roda quando as formas preparada e bruta diferem e a primeira tentativa não reportou um código de erro estrutural. Uma falha estrutural significa que a entrada está danificada ou não é a revisão de criptografia que você presumiu, e tentar novamente um arquivo quebrado com outra senha simplesmente queima uma segunda análise completa sobre entrada hostil; o raciocínio por trás desse reflexo está exposto em a nota sobre análise segura de PDFs não confiáveis. Observe também que não há fallback no lado da escrita, e essa assimetria é intencional. A leitura tolera o histórico, a escrita não: todo arquivo AES-256 novo recebe os bytes conformes

Quais senhas são rejeitadas de imediato, e o que é o erro 604?

O SASLprep pode recusar uma senha por completo, e quando o faz, a criptografia precisa falhar ruidosamente em vez de substituir algo silenciosamente. Encrypt e EncryptFile preparam tanto a senha de proprietário quanto a de usuário sempre que Strength é 3 ou 4, retornam 0 em caso de recusa, e definem LastErrorCode como PDFLIB_ERROR_PASSWORD_SASLPREP, que é 604. Duas famílias de entrada disparam isso. As tabelas de saída proibida rejeitam caracteres de controle (C.2.1 e C.2.2), pontos de código de uso privado (C.3), não-caracteres (C.4), substitutos isolados (C.5), U+FFFD (C.6), caracteres de descrição ideográfica (C.7), e os intervalos de controle de exibição e marcação (C.8 e C.9). Separadamente, a regra bidi da seção 6 da RFC 3454 rejeita qualquer string que contenha um caractere RandALCat da tabela D.1, a menos que a string comece e termine com um deles e não contenha nenhuma letra da esquerda para a direita

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

Essa regra bidi é a que vai surpreender sua central de suporte. Uma senha em árabe ou hebraico terminando com um dígito ocidental, ou uma com uma letra latina solta no meio, é recusada pela especificação mesmo parecendo perfeitamente razoável no campo de entrada. Apresente o 604 como uma mensagem sobre os caracteres da senha, não como uma falha genérica de criptografia, ou alguém vai passar uma tarde procurando um bug na sua derivação de chave

Limites honestos: NFKC, um LCat aproximado, e uma armadilha do Delphi

Duas partes da implementação são aproximações, e ambas merecem ser ditas claramente em vez de escondidas. A normalização NFKC é realizada pela API NormalizeString do Windows, carregada dinamicamente de Normaliz.dll. Quando essa biblioteca não está disponível, a string mapeada é usada sem normalizar, o que significa que os passos de mapeamento e proibição ainda rodam mas o dobramento de compatibilidade não. Na prática a DLL vem com todo release do Windows desde o Vista, então o caminho degradado é uma preocupação para pré-Vista e não Windows em vez de uma preocupação real, mas uma senha que dependesse do dobramento NFKC produziria bytes diferentes ali, e essa é uma divergência real, ainda que remota. A verificação bidi é a segunda aproximação: detectar caracteres LCat usa os intervalos de letras comuns em vez da tabela D.2 completa da RFC 3454, e a direção desse erro é o que a torna aceitável. Um caractere LCat perdido só pode fazer a regra bidi passar onde a especificação teria rejeitado, nunca o contrário, e nunca toca os passos de mapeamento ou normalização, então a sequência de bytes preparada de uma senha aceita permanece inalterada. O risco residual é portanto uma divergência de política em vez de uma divergência de bytes: uma senha de escrita exótica que uma implementação mais rígida recusaria completamente aceitar. Toda senha que os dois lados aceitam hasheia de forma idêntica, que é a propriedade da qual a interoperabilidade realmente depende

Por fim, uma armadilha de sintaxe do Delphi que custa uma hora se você nunca tiver caído nela antes. Quando uma função retorna um tipo procedural, atribuí-la sem parênteses não a chama. O compilador lê Proc := GetNormalizeProc; como tomar o endereço de GetNormalizeProc em si, depois reporta E2009 com a queixa nada útil de que as convenções de chamada diferem, porque o acessor usa a convenção padrão enquanto o tipo da API importada é stdcall. Os parênteses vazios são obrigatórios

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

A preparação de senha é um daqueles detalhes que nunca aparece em uma lista de recursos e decide se um documento criptografado sobrevive ao contato com um cliente em outra localidade. Os pontos de entrada Encrypt, EncryptFile, DecryptFile e SetPassword descritos aqui fazem parte da losLab PDF Developer Library Pascal Edition para Delphi e C++Builder, cuja página de produto traz a referência completa de criptografia e a tabela completa de códigos de erro