Um PDF AES-256 encriptado com uma password não-ASCII abre no programa que o escreveu e em mais lado nenhum. A causa é quase sempre um passo de preparação em falta: a ISO 32000-2 §7.6.4.3.3 exige que a password seja processada com o perfil SASLprep do stringprep antes de ser codificada em UTF-8 e sujeita a hash. O 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 password errada nem a história dos bits de permissão. Se os seus utilizadores estiverem a digitar uma password que nunca emitiu, a mecânica de repetição em o artigo sobre repetir passwords de PDF encriptado é o que quer, e se estiver a tentar apurar o que um ficheiro existente realmente impõe, a auditoria de encriptação e permissões cobre esse terreno. Este é mais estreito e mais estranho: a password está correta, o utilizador digitou-a corretamente, e o ficheiro continua a recusar-se a abrir noutro lugar
Por que razão abre uma password não-ASCII num leitor mas não noutro?
Porque os dois programas fazem hash a sequências de bytes diferentes a partir das mesmas teclas premidas. A derivação de chave da revisão 6 na ISO 32000-2 §7.6.4.3.3 recebe a password como bytes UTF-8, trunca a 127 bytes, acrescenta um salt, e executa o hash reforçado; o resultado é verificado contra as entradas /U e /O no dicionário de encriptação. 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 só tem uma coisa a dizer: password errada
Os bytes divergem porque o Unicode oferece várias formas de escrever aquilo que parece a mesma password. Uma password chinesa pode chegar como caracteres pré-compostos vindos de um método de introdução e como formas de compatibilidade vindas de outro. Uma password alemã ou francesa copiada de um processador de texto pode transportar um ESPAÇO SEM QUEBRA (U+00A0) onde o utilizador acredita haver um espaço comum, ou um HÍFEN SUAVE (U+00AD) que se apresenta como nada. O SASLprep existe para colapsar tudo isso numa única forma canónica antes de alguém fazer hash de seja o que for, para que cada implementação conforme derive a mesma chave a partir da mesma intenção
O que altera realmente o SASLprep numa password?
A RFC 4013 define o SASLprep como um perfil da estrutura stringprep na RFC 3454, e são quatro passos ordenados em vez de uma única transformação. 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 normalmente mapeados para nada) é eliminada por completo. Segue-se a normalização para Unicode NFKC, que é o passo que dobra caracteres de compatibilidade e sequências combinadas. Depois, a verificação de output proibido rejeita qualquer coisa nas tabelas C.2.1 a C.9. Por fim, a regra bidirecional da secção 6 da RFC 3454 é aplicada à string normalizada
O PDFlibPas implementa todo o perfil na unidade PDFlibSASLprep, que expõe um único ponto de entrada. PLSASLprepPassword recebe a password em bruto, escreve a forma preparada num parâmetro var, e devolve False quando a password tem de ser recusada. A função é deliberadamente total no caminho feliz: uma password apenas ASCII volta byte a byte idêntica, pelo que nada muda em relação às implementaçõ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 carácter cai em duas tabelas da RFC 3454 ao mesmo tempo, e as duas tabelas discordam. O ESPAÇO DE LARGURA ZERO (U+200B) cai dentro do intervalo C.1.2 U+2000 a U+200B, onde a regra diz para o mapear para U+0020, e cai também dentro do intervalo B.1 U+200B a U+200D, onde a regra diz para o eliminar. Leia o passo de mapeamento por qualquer ordem e obtém bytes diferentes da mesma password: a+U+200B+b prepara-se para a b segundo a C.1.2 e para ab segundo a B.1. A RFC 4013 nomeia ambas as tabelas e não diz qual vence, pelo que se trata de uma ambiguidade genuína na especificação em vez de um erro de leitura. O PDFlibPas testa primeiro a pertença à C.1.2 e mapeia por isso U+200B para um espaço, que é o comportamento em que outras implementações de stringprep amplamente usadas se estabeleceram; corresponder a elas é a única coisa que importa aqui, porque o objetivo é o acordo de bytes com o leitor que o cliente calhar de usar
Ler ficheiros antigos: preparada primeiro, em bruto depois
A correção cria o seu próprio problema de compatibilidade. Todo o ficheiro AES-256 escrito antes da alteração fez hash à password UTF-8 em bruto, pelo que tornar o leitor estritamente conforme bloquearia clientes fora dos seus próprios arquivos. O PDFlibPas resolve isto do lado da leitura tentando dois candidatos por ordem. TPDFDocument.SetPassword constrói uma lista de candidatos que começa com a forma preparada e recua para a forma em bruto, e só acrescenta a entrada preparada quando o documento é realmente AES-256 e as duas formas diferem. Para uma password ASCII, as formas são idênticas, a lista tem uma entrada, e o custo de todo o mecanismo é uma única comparação de strings. DecryptFile faz o mesmo ao longo do seu percurso direto de reescrita AES-256, chamando PLDirectDecryptFileAES256 com a password 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 mecanismo de recurso transporta uma proteção que vale a pena copiar. A segunda tentativa em DecryptFile só corre quando as formas preparada e em bruto 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 encriptação que assumiu, e repetir um ficheiro corrompido com outra password apenas queima uma segunda análise completa sobre entrada hostil; o raciocínio por detrás desse reflexo está exposto em a nota sobre analisar PDFs não fiáveis em segurança. Note também que não há recurso do lado da escrita, e essa assimetria é intencional. A leitura tolera a história, a escrita não: cada novo ficheiro AES-256 recebe os bytes conformes
Que passwords são rejeitadas por completo, e o que é o erro 604?
O SASLprep pode recusar uma password por completo, e quando o faz, a encriptação tem de falhar ruidosamente em vez de substituir silenciosamente algo. Encrypt e EncryptFile preparam tanto a password de proprietário como a de utilizador sempre que Strength é 3 ou 4, devolvem 0 em caso de recusa, e definem LastErrorCode para PDFLIB_ERROR_PASSWORD_SASLPREP, que é 604. Duas famílias de entrada desencadeiam-no. As tabelas de output proibido rejeitam caracteres de controlo (C.2.1 e C.2.2), pontos de código de uso privado (C.3), não-caracteres (C.4), surrogates isolados (C.5), U+FFFD (C.6), caracteres de descrição ideográfica (C.7), e os intervalos de controlo de apresentação e etiquetagem (C.8 e C.9). Separadamente, a regra bidi da secção 6 da RFC 3454 rejeita qualquer string que contenha um carácter RandALCat da tabela D.1 a menos que a string comece e termine com um e não contenha letras da esquerda para a direita de todo
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 a sua linha de apoio. Uma password árabe ou hebraica que termina num dígito ocidental, ou uma com uma letra latina isolada no meio, é recusada pela especificação mesmo que pareça perfeitamente razoável no campo de introdução. Apresente o 604 como uma mensagem sobre os caracteres da password, não como uma falha genérica de encriptação, ou alguém vai passar uma tarde à procura de 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 com clareza em vez de escondidas. A normalização NFKC é executada pela API NormalizeString do Windows, carregada dinamicamente a partir de Normaliz.dll. Quando essa biblioteca não está disponível, a string mapeada é usada não normalizada, o que significa que os passos de mapeamento e proibição continuam a correr mas o dobramento de compatibilidade não. Na prática, a DLL foi disponibilizada com todos os lançamentos do Windows desde o Vista, pelo que o percurso degradado é uma preocupação pré-Vista e não-Windows em vez de uma preocupação real, mas uma password que dependesse do dobramento NFKC produziria bytes diferentes ali e essa é uma divergência real, embora remota. A verificação bidi é a segunda aproximação: detetar caracteres LCat usa os intervalos de letras comuns em vez da tabela D.2 completa da RFC 3454, e o sentido desse erro é o que o torna aceitável. Um carácter LCat não detetado só pode fazer a regra bidi passar onde a especificação a teria rejeitado, nunca o contrário, e nunca toca nos passos de mapeamento ou normalização, pelo que a sequência de bytes preparada de uma password aceite fica inalterada. O risco residual é por isso uma divergência de política em vez de uma divergência de bytes: uma password de escrita exótica que uma implementação mais estrita se recusaria a aceitar de todo. Toda a password que ambos os lados aceitam faz hash de forma idêntica, que é a propriedade de que a interoperabilidade realmente depende
Por fim, uma armadilha de sintaxe do Delphi que custa uma hora se ainda não a tiver encontrado. Quando uma função devolve 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, e depois reporta E2009 com a queixa pouco útil de que as convenções de chamada diferem, porque o acessor usa a convenção predefinida enquanto o tipo de API importado é 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 passwords é um daqueles detalhes que nunca aparece numa lista de funcionalidades e decide se um documento encriptado sobrevive ao contacto com um cliente noutra localidade. Os pontos de entrada Encrypt, EncryptFile, DecryptFile e SetPassword aqui descritos fazem parte da losLab PDF Developer Library Pascal Edition para Delphi e C++Builder, cuja página de produto contém a referência completa de encriptação e a tabela completa de códigos de erro