Τεχνικό Άρθρο

SASLprep AES-256 Κωδικοί PDF σε Delphi με PDFlibPas

Ένα AES-256 PDF κρυπτογραφημένο με μη ASCII κωδικό πρόσβασης ανοίγει στο πρόγραμμα που το έγραψε και πουθενά αλλού. Η αιτία είναι σχεδόν πάντα ένα βήμα προετοιμασίας που λείπει: το ISO 32000-2 §7.6.4.3.3 απαιτεί τον κωδικό να επεξεργαστεί με το προφίλ SASLprep του stringprep πριν κωδικοποιηθεί σε UTF-8 και κερματιστεί (hashed). Το PDFlibPas, η βιβλιοθήκη PDF για Delphi και C++Builder, εκτελεί αυτή την προετοιμασία μέσα στις Encrypt, EncryptFile και DecryptFile

Αυτό δεν είναι η ιστορία του λάθος κωδικού ούτε η ιστορία των bits αδειών. Αν οι χρήστες σας πληκτρολογούν έναν κωδικό που δεν εκδώσατε ποτέ, ο μηχανισμός επανάληψης στο άρθρο για την επανάληψη κωδικών κρυπτογραφημένων PDF είναι αυτό που θέλετε, και αν προσπαθείτε να καταλάβετε τι πραγματικά επιβάλλει ένα υπάρχον αρχείο, ο έλεγχος κρυπτογράφησης και αδειών καλύπτει αυτό το έδαφος. Αυτό εδώ είναι πιο στενό και πιο περίεργο: ο κωδικός είναι σωστός, ο χρήστης τον πληκτρολόγησε σωστά, και το αρχείο εξακολουθεί να αρνείται να ανοίξει κάπου αλλού

Γιατί ένας μη ASCII κωδικός ανοίγει σε έναν reader αλλά όχι σε άλλον;

Επειδή τα δύο προγράμματα κερματίζουν διαφορετικές ακολουθίες byte από τα ίδια πληκτρολογήματα. Η παραγωγή κλειδιού αναθεώρησης 6 στο ISO 32000-2 §7.6.4.3.3 παίρνει τον κωδικό ως bytes UTF-8, τον περικόπτει σε 127 bytes, προσαρτά ένα salt, και τρέχει τον ενισχυμένο (hardened) hash· το αποτέλεσμα ελέγχεται έναντι των καταχωρίσεων /U και /O στο λεξικό κρυπτογράφησης. Τίποτα σε αυτή την αλυσίδα δεν είναι ασαφές. Ένα διαφορετικό byte οπουδήποτε στην είσοδο παράγει ένα εντελώς διαφορετικό digest, η επικύρωση αποτυγχάνει, και ο reader έχει ακριβώς ένα πράγμα να πει: λάθος κωδικός

Τα bytes αποκλίνουν επειδή το Unicode προσφέρει πολλούς τρόπους να πληκτρολογήσετε αυτό που φαίνεται ο ίδιος κωδικός. Ένας κινεζικός κωδικός μπορεί να φτάσει ως προσυντεθειμένοι χαρακτήρες από μια μέθοδο εισαγωγής και ως μορφές συμβατότητας από άλλη. Ένας γερμανικός ή γαλλικός κωδικός που αντιγράφηκε από επεξεργαστή κειμένου μπορεί να φέρει ένα NO-BREAK SPACE (U+00A0) εκεί όπου ο χρήστης πιστεύει ότι υπάρχει κανονικό διάστημα, ή ένα SOFT HYPHEN (U+00AD) που αποδίδεται ως τίποτα απολύτως. Το SASLprep υπάρχει για να συμπτύξει όλα αυτά σε μία κανονική μορφή πριν κάποιος κερματίσει οτιδήποτε, ώστε κάθε συμμορφούμενη υλοποίηση να παράγει το ίδιο κλειδί από την ίδια πρόθεση

Τι αλλάζει στην πραγματικότητα το SASLprep σε έναν κωδικό;

Το RFC 4013 ορίζει το SASLprep ως προφίλ του πλαισίου stringprep στο RFC 3454, και είναι τέσσερα διατεταγμένα βήματα αντί για έναν μετασχηματισμό. Η αντιστοίχιση έρχεται πρώτη: ο πίνακας C.1.2 του RFC 3454 (μη ASCII κενά) αντιστοιχίζεται στο U+0020, και ο πίνακας B.1 (χαρακτήρες που συνήθως αντιστοιχίζονται σε τίποτα) διαγράφεται εντελώς. Ακολουθεί κανονικοποίηση σε Unicode NFKC, που είναι το βήμα που συμπτύσσει χαρακτήρες συμβατότητας και ακολουθίες συνδυασμού. Μετά ο έλεγχος απαγορευμένης εξόδου απορρίπτει οτιδήποτε βρίσκεται στους πίνακες C.2.1 έως C.9. Τέλος ο κανόνας διεύθυνσης (bidirectional) από το RFC 3454 τμήμα 6 εφαρμόζεται στο κανονικοποιημένο string

Το PDFlibPas υλοποιεί ολόκληρο το προφίλ στη μονάδα PDFlibSASLprep, η οποία εκθέτει ένα μόνο σημείο εισόδου. Η PLSASLprepPassword παίρνει τον ακατέργαστο κωδικό, γράφει την προετοιμασμένη μορφή σε παράμετρο var, και επιστρέφει False όταν ο κωδικός πρέπει να απορριφθεί. Η συνάρτηση είναι σκόπιμα ολοκληρωμένη στη διαδρομή επιτυχίας: ένας αμιγώς ASCII κωδικός επιστρέφει byte-identical, οπότε τίποτα δεν αλλάζει σε υπάρχουσες αναπτύξεις

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;

Η ασάφεια του U+200B που οι πίνακες δεν επιλύουν

Ένα code point καταλήγει σε δύο πίνακες RFC 3454 ταυτόχρονα, και οι δύο πίνακες διαφωνούν. Το ZERO WIDTH SPACE (U+200B) πέφτει μέσα στο εύρος C.1.2 U+2000 έως U+200B, όπου ο κανόνας λέει να αντιστοιχιστεί στο U+0020, και πέφτει επίσης μέσα στο εύρος B.1 U+200B έως U+200D, όπου ο κανόνας λέει να διαγραφεί. Διαβάστε το βήμα αντιστοίχισης με οποιαδήποτε σειρά και παίρνετε διαφορετικά bytes από τον ίδιο κωδικό: το a+U+200B+b προετοιμάζεται σε a b κάτω από το C.1.2 και σε ab κάτω από το B.1. Το RFC 4013 ονομάζει και τους δύο πίνακες και δεν λέει ποιος υπερισχύει, οπότε αυτή είναι μια γνήσια ασάφεια στην προδιαγραφή και όχι σφάλμα ανάγνωσης. Το PDFlibPas ελέγχει πρώτα τη συμμετοχή στο C.1.2 και επομένως αντιστοιχίζει το U+200B σε διάστημα, που είναι η συμπεριφορά στην οποία κατέληξαν άλλες ευρέως αναπτυγμένες υλοποιήσεις stringprep· η αντιστοίχιση με αυτές είναι το μόνο που έχει σημασία εδώ, επειδή ο στόχος είναι συμφωνία byte με όποιον reader τυχαίνει να χρησιμοποιεί ο πελάτης

Ανάγνωση παλιών αρχείων: προετοιμασμένος πρώτα, ακατέργαστος δεύτερος

Η διόρθωση δημιουργεί το δικό της πρόβλημα συμβατότητας. Κάθε αρχείο AES-256 που γράφτηκε πριν την αλλαγή κερμάτισε τον ακατέργαστο κωδικό UTF-8, οπότε το να κάνει τον reader αυστηρά συμμορφούμενο θα κλείδωνε τους πελάτες έξω από τα δικά τους αρχεία. Το PDFlibPas το λύνει αυτό στην πλευρά ανάγνωσης δοκιμάζοντας δύο υποψηφίους με σειρά. Η TPDFDocument.SetPassword χτίζει μια λίστα υποψηφίων που ξεκινά με την προετοιμασμένη μορφή και επιστρέφει στην ακατέργαστη μορφή, και προσθέτει την προετοιμασμένη καταχώριση μόνο όταν το έγγραφο είναι πραγματικά AES-256 και οι δύο μορφές διαφέρουν. Για έναν κωδικό ASCII οι μορφές είναι πανομοιότυπες, η λίστα κρατά μία καταχώριση, και το κόστος όλου του μηχανισμού είναι μία μόνο σύγκριση string. Η DecryptFile κάνει το ίδιο πράγμα στη δική της απευθείας διαδρομή επανεγγραφής AES-256, καλώντας την PLDirectDecryptFileAES256 με τον προετοιμασμένο κωδικό πρώτα

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;

Η εναλλακτική λύση φέρει μία φρουρά που αξίζει να αντιγραφεί. Η δεύτερη προσπάθεια στην DecryptFile τρέχει μόνο όταν η προετοιμασμένη και η ακατέργαστη μορφή διαφέρουν και η πρώτη προσπάθεια δεν ανέφερε κωδικό σκληρού σφάλματος. Μια δομική αποτυχία σημαίνει ότι η είσοδος είναι κατεστραμμένη ή δεν είναι η αναθεώρηση κρυπτογράφησης που υποθέσατε, και η επανάληψη ενός κατεστραμμένου αρχείου με άλλον κωδικό απλώς καίει μια δεύτερη πλήρη ανάλυση πάνω σε εχθρική είσοδο· η λογική πίσω από αυτό το αντανακλαστικό εκτίθεται στη σημείωση για την ασφαλή ανάλυση μη αξιόπιστων PDF. Σημειώστε επίσης ότι δεν υπάρχει εναλλακτική στην πλευρά εγγραφής, και αυτή η ασυμμετρία είναι σκόπιμη. Η ανάγνωση ανέχεται το ιστορικό, η εγγραφή όχι: κάθε νέο αρχείο AES-256 παίρνει τα συμμορφούμενα bytes

Ποιοι κωδικοί απορρίπτονται εντελώς, και τι είναι το σφάλμα 604;

Το SASLprep μπορεί να αρνηθεί έναν κωδικό εντελώς, και όταν το κάνει, η κρυπτογράφηση πρέπει να αποτύχει με θόρυβο αντί να αντικαταστήσει κάτι σιωπηλά. Οι Encrypt και EncryptFile προετοιμάζουν και τον κωδικό ιδιοκτήτη και τον κωδικό χρήστη όποτε το Strength είναι 3 ή 4, επιστρέφουν 0 σε άρνηση, και ορίζουν το LastErrorCode σε PDFLIB_ERROR_PASSWORD_SASLPREP, που είναι 604. Δύο οικογένειες εισόδου το ενεργοποιούν. Οι πίνακες απαγορευμένης εξόδου απορρίπτουν χαρακτήρες ελέγχου (C.2.1 και C.2.2), code points ιδιωτικής χρήσης (C.3), μη χαρακτήρες (C.4), μεμονωμένα surrogates (C.5), U+FFFD (C.6), χαρακτήρες περιγραφής ιδεογραμμάτων (C.7), και τα εύρη ελέγχου εμφάνισης και σήμανσης (C.8 και C.9). Ξεχωριστά, ο κανόνας bidi του RFC 3454 τμήμα 6 απορρίπτει κάθε string που περιέχει έναν χαρακτήρα RandALCat από τον πίνακα D.1 εκτός αν το string ξεκινά και τελειώνει με έναν τέτοιο χαρακτήρα και δεν περιέχει καθόλου γράμματα από αριστερά προς τα δεξιά

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;

Αυτός ο κανόνας bidi είναι αυτός που θα εκπλήξει το γραφείο υποστήριξής σας. Ένας αραβικός ή εβραϊκός κωδικός που τελειώνει σε δυτικό ψηφίο, ή ένας με έναν αδέσποτο λατινικό χαρακτήρα στη μέση, απορρίπτεται από την προδιαγραφή ακόμη κι αν φαίνεται απόλυτα λογικός στο πεδίο εισαγωγής. Παρουσιάστε το 604 ως μήνυμα για τους χαρακτήρες του κωδικού, όχι ως γενική αποτυχία κρυπτογράφησης, αλλιώς κάποιος θα ξοδέψει ένα απόγευμα ψάχνοντας για bug στην παραγωγή κλειδιού σας

Ειλικρινή όρια: NFKC, ένα προσεγγιστικό LCat, και μία παγίδα Delphi

Δύο μέρη της υλοποίησης είναι προσεγγίσεις, και τα δύο αξίζει να δηλωθούν καθαρά αντί να θαφτούν. Η κανονικοποίηση NFKC εκτελείται από το Windows API NormalizeString, φορτωμένο δυναμικά από το Normaliz.dll. Όταν αυτή η βιβλιοθήκη δεν είναι διαθέσιμη το αντιστοιχισμένο string χρησιμοποιείται μη κανονικοποιημένο, που σημαίνει ότι τα βήματα αντιστοίχισης και απαγόρευσης εξακολουθούν να τρέχουν αλλά η σύμπτυξη συμβατότητας όχι. Στην πράξη το DLL διατίθεται με κάθε έκδοση Windows από το Vista, οπότε η υποβαθμισμένη διαδρομή είναι ζήτημα προ-Vista και μη-Windows και όχι ζωντανό, αλλά ένας κωδικός που βασίζεται σε σύμπτυξη NFKC θα παρήγαγε διαφορετικά bytes εκεί και αυτή είναι μια πραγματική, αν και απομακρυσμένη, απόκλιση. Ο έλεγχος bidi είναι η δεύτερη προσέγγιση: η ανίχνευση χαρακτήρων LCat χρησιμοποιεί τα κοινά εύρη γραμμάτων αντί για τον πλήρη πίνακα D.2 του RFC 3454, και η κατεύθυνση αυτού του σφάλματος είναι αυτό που το κάνει αποδεκτό. Ένας χαμένος χαρακτήρας LCat μπορεί μόνο να κάνει τον κανόνα bidi να περάσει εκεί όπου η προδιαγραφή θα είχε απορρίψει, ποτέ το αντίστροφο, και ποτέ δεν αγγίζει τα βήματα αντιστοίχισης ή κανονικοποίησης, οπότε η προετοιμασμένη ακολουθία byte ενός αποδεκτού κωδικού παραμένει αμετάβλητη. Ο υπολειπόμενος κίνδυνος είναι επομένως απόκλιση πολιτικής και όχι απόκλιση byte: ένας κωδικός με εξωτικό σύστημα γραφής που μια αυστηρότερη υλοποίηση θα αρνούνταν εντελώς να δεχτεί. Κάθε κωδικός που αποδέχονται και οι δύο πλευρές κερματίζεται πανομοιότυπα, που είναι η ιδιότητα από την οποία πραγματικά εξαρτάται η διαλειτουργικότητα

Τέλος, μια παγίδα σύνταξης Delphi που κοστίζει μία ώρα αν δεν την έχετε συναντήσει πριν. Όταν μια συνάρτηση επιστρέφει διαδικαστικό τύπο, η ανάθεσή της χωρίς παρενθέσεις δεν την καλεί. Ο μεταγλωττιστής διαβάζει το Proc := GetNormalizeProc; ως λήψη της διεύθυνσης της ίδιας της GetNormalizeProc, και μετά αναφέρει E2009 με το μη χρήσιμο παράπονο ότι οι συμβάσεις κλήσης διαφέρουν, επειδή ο accessor χρησιμοποιεί την προεπιλεγμένη σύμβαση ενώ ο εισαγόμενος τύπος API είναι stdcall. Οι κενές παρενθέσεις είναι υποχρεωτικές

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;

Η προετοιμασία κωδικού είναι μία από εκείνες τις λεπτομέρειες που ποτέ δεν εμφανίζεται σε λίστα χαρακτηριστικών και αποφασίζει αν ένα κρυπτογραφημένο έγγραφο επιβιώνει από την επαφή με έναν πελάτη σε άλλο locale. Τα σημεία εισόδου Encrypt, EncryptFile, DecryptFile και SetPassword που περιγράφονται εδώ είναι μέρος της losLab PDF Developer Library Pascal Edition για Delphi και C++Builder, της οποίας η σελίδα προϊόντος φέρει την πλήρη αναφορά κρυπτογράφησης και τον πλήρη πίνακα κωδικών σφάλματος