Articolo tecnico

SASLprep AES-256 password PDF in Delphi con PDF Library for Delphi

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

Diagramma PDF Library for Delphi della stessa password PDF non-ASCII con hash in due diverse sequenze di byte UTF-8, dove il reader che salta la preparazione fallisce il controllo /U e il reader preparato con SASLprep deriva la chiave funzionante
Il lettore che salta la preparazione fa l'hashing di byte diversi, così la validazione AES-256 collassa in un falso rapporto di password errata

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

PDF Library for Delphi 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, poi NFKC, poi output proibito, poi la regola bidi
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 elimina SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 mappa NBSP su U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC riduce ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC riduce ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' l'ASCII non viene mai toccato
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. PDF Library for Delphi 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. PDF Library for Delphi 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 e 4 sono i due valori AES-256; entrambi vengono preparati prima dell'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' è ciò che SASLprep ha prodotto e ciò che qualsiasi lettore conforme calcola,
    // quindi la forma ASCII pura apre un file creato con la forma con soft-hyphen
    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 è un carattere di controllo C.2.1, quindi la preparazione rifiuta la 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

Diagramma PDF Library for Delphi dei quattro passi ordinati SASLprep RFC 4013 che mappano gli spazi non-ASCII, cancellano i caratteri mappati al nulla, ripiegano le forme di compatibilità con NFKC e eseguono i controlli prohibited-output e bidirezionale per produrre un'unica password preparata stabile
Mappatura, normalizzazione NFKC, scansione dell'output vietato e regola bidi girano in ordine e lasciano intatte le password solo ASCII

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

Diagramma PDF Library for Delphi della cifratura AES-256 che rifiuta le password i cui caratteri toccano le tabelle prohibited-output da C.2.1 a C.9 o violano la regola bidirezionale RFC 3454, restituendo zero con LastErrorCode 604
Due famiglie di input innescano il percorso di rifiuto: le tabelle di code point vietate e la regola bidirezionale della RFC 3454
type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // carica Normaliz.dll al primo utilizzo
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: viene letta come @GetNormalizeProc, le convenzioni differiscono
  Proc := GetNormalizeProc();    // corretto: chiama l'accessor e assegna il suo risultato
  if not Assigned(Proc) then
    Exit;                        // NFKC non disponibile, la stringa mappata viene usata così com'è
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