Un PDF AES-256 cifrato con una password non ASCII si apre nel programma che lo ha scritto e in nessun altro. La causa è quasi sempre un passaggio di preparazione mancante: ISO 32000-2 §7.6.4.3.3 richiede che la password sia elaborata con il profilo SASLprep di stringprep prima di essere codificata in UTF-8 e sottoposta a hash. PDFlibPas, la libreria PDF per Delphi e C++Builder, esegue quella preparazione dentro Encrypt, EncryptFile e DecryptFile
Questa non è la storia della password sbagliata e non è la storia dei bit di permesso. Se i tuoi utenti digitano una password che non hai mai emesso, il meccanismo di ritentativo descritto in l'articolo sul ritentativo delle password PDF cifrate è ciò che vuoi, e se stai cercando di capire cosa applichi realmente un file esistente, l'audit di cifratura e permessi copre quel terreno. Questo è più ristretto e più strano: la password è corretta, l'utente l'ha digitata correttamente, e il file continua a rifiutarsi di aprirsi altrove
Perché una password non ASCII si apre in un reader ma non in un altro?
Perché i due programmi sottopongono a hash sequenze di byte diverse a partire dalle stesse battute di tastiera. La derivazione della chiave di revisione 6 in ISO 32000-2 §7.6.4.3.3 prende la password come byte UTF-8, la tronca a 127 byte, appende un salt, ed esegue l'hash rafforzato; il risultato viene verificato contro le voci /U e /O nel dizionario di cifratura. Niente in quella catena è impreciso. Un solo byte diverso in un punto qualsiasi dell'input produce un digest completamente diverso, la validazione fallisce, e il reader ha esattamente una cosa da dire: password sbagliata
I byte divergono perché Unicode offre diversi modi di digitare quella che sembra la stessa password. Una password cinese può arrivare come caratteri precomposti da un metodo di input e come forme di compatibilità da un altro. Una password tedesca o francese copiata da un elaboratore di testi può portare uno SPAZIO SENZA INTERRUZIONE (U+00A0) dove l'utente crede ci sia uno spazio ordinario, o un TRATTINO MORBIDO (U+00AD) che viene renderizzato come nulla. SASLprep esiste per collassare tutto questo in un'unica forma canonica prima che chiunque sottoponga a hash qualsiasi cosa, così che ogni implementazione conforme derivi la stessa chiave dalla stessa intenzione
Cosa cambia realmente SASLprep in una password?
RFC 4013 definisce SASLprep come un profilo del framework stringprep in RFC 3454, ed è composto da quattro passaggi ordinati piuttosto che da una singola trasformazione. Prima viene la mappatura: la tabella C.1.2 di RFC 3454 (spazi non ASCII) viene mappata a U+0020, e la tabella B.1 (caratteri comunemente mappati a niente) viene eliminata del tutto. Segue la normalizzazione a Unicode NFKC, che è il passaggio che ripiega caratteri di compatibilità e sequenze combinanti. Poi il controllo di output proibito rifiuta qualsiasi cosa nelle tabelle da C.2.1 a C.9. Infine la regola bidirezionale della sezione 6 di RFC 3454 viene applicata alla stringa normalizzata
PDFlibPas implementa l'intero profilo nell'unit PDFlibSASLprep, che espone un unico punto di ingresso. PLSASLprepPassword prende la password grezza, scrive la forma preparata in un parametro var, e restituisce False quando la password deve essere rifiutata. La funzione è deliberatamente totale nel percorso felice: una password solo ASCII ritorna byte-identica, quindi nulla cambia nelle installazioni esistenti
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;
L'ambiguità U+200B che le tabelle non risolvono
Un code point atterra in due tabelle RFC 3454 contemporaneamente, e le due tabelle sono in disaccordo. ZERO WIDTH SPACE (U+200B) cade dentro l'intervallo C.1.2 da U+2000 a U+200B, dove la regola dice di mapparlo a U+0020, e cade anche dentro l'intervallo B.1 da U+200B a U+200D, dove la regola dice di eliminarlo. Leggi il passaggio di mappatura in un ordine o nell'altro e ottieni byte diversi dalla stessa password: a+U+200B+b si prepara come a b secondo C.1.2 e come ab secondo B.1. RFC 4013 nomina entrambe le tabelle e non dice quale prevalga, quindi questa è un'ambiguità genuina nella specifica piuttosto che un errore di lettura. PDFlibPas verifica prima l'appartenenza a C.1.2 e quindi mappa U+200B a uno spazio, che è il comportamento su cui si sono assestate altre implementazioni stringprep ampiamente diffuse; allinearsi a loro è l'unica cosa che conta qui, perché l'obiettivo è l'accordo sui byte con qualunque reader capiti di usare il cliente
Leggere file vecchi: prima preparata, poi grezza
La correzione crea un proprio problema di compatibilità. Ogni file AES-256 scritto prima della modifica sottoponeva a hash la password UTF-8 grezza, quindi rendere il reader strettamente conforme escluderebbe i clienti dai propri archivi. PDFlibPas risolve questo lato lettura provando due candidati in ordine. TPDFDocument.SetPassword costruisce una lista di candidati che inizia con la forma preparata e ricade sulla forma grezza, e aggiunge la voce preparata solo quando il documento è effettivamente AES-256 e le due forme differiscono. Per una password ASCII le forme sono identiche, la lista contiene una sola voce, e il costo dell'intero meccanismo è un singolo confronto di stringhe. DecryptFile fa la stessa cosa lungo il proprio percorso di riscrittura diretta AES-256, chiamando PLDirectDecryptFileAES256 prima con la password preparata
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;
Il fallback porta con sé una protezione che vale la pena replicare. Il secondo tentativo in DecryptFile viene eseguito solo quando la forma preparata e quella grezza differiscono e il primo tentativo non ha riportato un codice di errore grave. Un fallimento strutturale significa che l'input è danneggiato o non è la revisione di cifratura che avevi supposto, e ritentare un file rotto con un'altra password brucia semplicemente una seconda analisi completa su input ostile; il ragionamento dietro quel riflesso è esposto in la nota sul parsing sicuro di PDF non fidati. Nota anche che non c'è fallback sul lato scrittura, e quell'asimmetria è intenzionale. La lettura tollera la storia, la scrittura no: ogni nuovo file AES-256 ottiene i byte conformi
Quali password vengono rifiutate del tutto, e cos'è l'errore 604?
SASLprep può rifiutare del tutto una password, e quando lo fa, la cifratura deve fallire rumorosamente piuttosto che sostituire silenziosamente qualcosa. Encrypt e EncryptFile preparano sia la password proprietario sia quella utente ogni volta che Strength è 3 o 4, restituiscono 0 in caso di rifiuto, e impostano LastErrorCode a PDFLIB_ERROR_PASSWORD_SASLPREP, che è 604. Due famiglie di input lo attivano. Le tabelle di output proibito rifiutano caratteri di controllo (C.2.1 e C.2.2), code point a uso privato (C.3), non-caratteri (C.4), surrogati isolati (C.5), U+FFFD (C.6), caratteri di descrizione ideografica (C.7), e gli intervalli di controllo di visualizzazione e tagging (C.8 e C.9). Separatamente, la regola bidi della sezione 6 di RFC 3454 rifiuta qualsiasi stringa che contenga un carattere RandALCat dalla tabella D.1 a meno che la stringa non inizi e finisca entrambe con uno di essi e non contenga alcuna lettera da sinistra a destra
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;
Quella regola bidi è quella che sorprenderà il tuo help desk. Una password araba o ebraica che termina con una cifra occidentale, o una con una lettera latina estranea nel mezzo, viene rifiutata dalla specifica anche se sembra perfettamente ragionevole nel campo di inserimento. Presenta il 604 come un messaggio sui caratteri della password, non come un fallimento generico di cifratura, o qualcuno passerà un pomeriggio a cercare un bug nella tua derivazione delle chiavi
Limiti onesti: NFKC, un LCat approssimato, e una trappola Delphi
Due parti dell'implementazione sono approssimazioni, ed entrambe meritano di essere dichiarate chiaramente piuttosto che nascoste. La normalizzazione NFKC viene eseguita dalla API Windows NormalizeString, caricata dinamicamente da Normaliz.dll. Quando quella libreria non è disponibile, viene usata la stringa mappata non normalizzata, il che significa che i passaggi di mappatura e proibizione vengono comunque eseguiti ma il ripiegamento di compatibilità no. In pratica la DLL è stata distribuita con ogni versione di Windows a partire da Vista, quindi il percorso degradato è una preoccupazione pre-Vista e non Windows piuttosto che attuale, ma una password che si basasse sul ripiegamento NFKC produrrebbe byte diversi lì e quella è una divergenza reale, seppur remota. Il controllo bidi è la seconda approssimazione: rilevare i caratteri LCat usa gli intervalli di lettere comuni invece della tabella completa D.2 di RFC 3454, e la direzione di quell'errore è ciò che lo rende accettabile. Un carattere LCat mancato può solo far passare la regola bidi dove la specifica lo avrebbe rifiutato, mai il contrario, e non tocca mai i passaggi di mappatura o normalizzazione, quindi la sequenza di byte preparata di una password accettata resta invariata. Il rischio residuo è quindi una divergenza di policy piuttosto che una divergenza di byte: una password in scrittura esotica che un'implementazione più rigida rifiuterebbe del tutto di accettare. Ogni password che entrambe le parti accettano produce lo stesso hash in modo identico, che è la proprietà da cui dipende realmente l'interoperabilità
Infine, una trappola sintattica Delphi che costa un'ora se non ci sei già incappato prima. Quando una funzione restituisce un tipo procedurale, assegnarlo senza parentesi non lo chiama. Il compilatore legge Proc := GetNormalizeProc; come prendere l'indirizzo di GetNormalizeProc stesso, poi riporta E2009 con il fastidioso reclamo che le convenzioni di chiamata differiscono, perché l'accessor usa la convenzione predefinita mentre il tipo API importato è stdcall. Le parentesi vuote sono obbligatorie
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;
La preparazione della password è uno di quei dettagli che non compare mai in un elenco di funzionalità e che decide se un documento cifrato sopravvive al contatto con un cliente in un'altra locale. I punti di ingresso Encrypt, EncryptFile, DecryptFile e SetPassword descritti qui fanno parte di losLab PDF Developer Library Pascal Edition per Delphi e C++Builder, la cui pagina prodotto porta il riferimento completo di cifratura e la tabella completa dei codici di errore