Artigo Técnico

SASLprep AES-256 Senhas de PDF em Delphi com PDF Library for Delphi

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. PDF Library for Delphi, 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

Diagrama do PDF Library for Delphi da mesma senha de PDF não ASCII gerando hash para duas sequências de bytes UTF-8 diferentes, em que o reader que pula a preparação falha na verificação /U e o reader preparado com SASLprep deriva a chave funcional
O leitor que ignora a preparação faz hash de bytes diferentes, então a validação AES-256 desaba em um relatório falso de senha incorreta

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

PDF Library for Delphi 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: mapeamento, depois NFKC, depois saída proibida, depois a regra bidi
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 exclui SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 mapeia NBSP para U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC dobra ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC dobra ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII nunca é tocado
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. PDF Library for Delphi 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. PDF Library for Delphi 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 e 4 são os dois valores AES-256; ambos são preparados antes do hash
    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' é o que o SASLprep produziu e o que qualquer leitor conforme calcula,
    // então a forma ASCII simples abre um arquivo criado com a forma soft-hyphen
    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 é um caractere de controle C.2.1, então a preparação recusa a senha
    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 (erro SASLprep)
        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

Diagrama do PDF Library for Delphi das quatro etapas ordenadas do SASLprep da RFC 4013 mapeando espaços não ASCII, apagando caracteres mapeados para nada, dobrando formas de compatibilidade com NFKC e executando as verificações de saída proibida e bidirecional para produzir uma senha preparada estável
Mapeamento, normalização NFKC, a varredura de saída proibida e a regra bidi rodam em ordem e deixam senhas somente ASCII intactas

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

Diagrama do PDF Library for Delphi da criptografia AES-256 recusando senhas cujos caracteres atingem as tabelas de saída proibida C.2.1 a C.9 ou violam a regra bidirecional da RFC 3454, retornando zero com LastErrorCode 604
Duas famílias de entrada ativam o caminho de recusa: as tabelas de code points proibidos e a regra bidirecional da RFC 3454
type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // carrega Normaliz.dll no primeiro uso
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: lido como @GetNormalizeProc, convenções diferem
  Proc := GetNormalizeProc();    // correto: chama o acessor e atribui seu resultado
  if not Assigned(Proc) then
    Exit;                        // NFKC indisponível, a string mapeada é usada como está
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