מאמר טכני

סיסמאות PDF ב-AES-256 לא-אסקי בדלפי עם SASLprep

PDF מוצפן ב-AES-256 עם סיסמה לא-אסקי נפתח בתוכנית שכתבה אותו ובשום מקום אחר. הסיבה כמעט תמיד היא שלב הכנה חסר: תקן ISO 32000-2 §7.6.4.3.3 דורש שהסיסמה תעובד עם פרופיל SASLprep של stringprep לפני שהיא מקודדת ב-UTF-8 ומתגבבת. PDFlibPas, ספריית ה-PDF עבור דלפי ו-C++Builder, מבצעת את ההכנה הזו בתוך Encrypt, EncryptFile ו-DecryptFile

זה לא סיפור הסיסמה השגויה ולא סיפור סיביות ההרשאה. אם המשתמשים שלך מקלידים סיסמה שמעולם לא הנפקת, מנגנון הניסיון החוזר בהמאמר על ניסיון חוזר לסיסמאות PDF מוצפנות הוא מה שאתה רוצה, ואם אתה מנסה לברר מה קובץ קיים בפועל אוכף, ביקורת ההצפנה וההרשאות מכסה את הקרקע הזו. זה צר ומוזר יותר: הסיסמה נכונה, המשתמש הקליד אותה נכון, והקובץ עדיין מסרב להיפתח במקום אחר

למה סיסמה לא-אסקי נפתחת בקורא אחד אך לא באחר?

מפני ששתי התוכניות מתגבבות רצפי בייטים שונים מאותן הקשות. גזירת המפתח ברוויזיה 6 בתקן ISO 32000-2 §7.6.4.3.3 לוקחת את הסיסמה כבייטי UTF-8, קוצצת ל-127 בייטים, מוסיפה מלח, ומריצה את ה-hash המחוזק; התוצאה נבדקת מול הרשומות /U ו-/O במילון ההצפנה. שום דבר בשרשרת הזו אינו מטושטש. בייט אחד שונה בכל מקום בקלט מייצר תקציר שונה לחלוטין, האימות נכשל, ולקורא יש בדיוק דבר אחד שהוא יכול לומר: סיסמה שגויה

הבייטים נבדלים כי יוניקוד מציע כמה דרכים להקליד את מה שנראה כאותה סיסמה. סיסמה סינית עשויה להגיע כתווים מורכבים-מראש משיטת קלט אחת וכצורות תאימות מאחרת. סיסמה גרמנית או צרפתית שהועתקה ממעבד תמלילים עשויה לשאת רווח בלתי-שביר (U+00A0) במקום שבו המשתמש מאמין שיש רווח רגיל, או מקף רך (U+00AD) שמוצג כלא כלום. SASLprep קיים כדי לקפל את כל אלה לצורה קנונית אחת לפני שמישהו מתגבב משהו, כך שכל יישום תואם גוזר את אותו מפתח מאותה כוונה

מה בדיוק SASLprep משנה בסיסמה?

RFC 4013 מגדיר את SASLprep כפרופיל של מסגרת ה-stringprep ב-RFC 3454, וזה ארבעה שלבים מסודרים ולא טרנספורמציה אחת. מיפוי בא ראשון: טבלה C.1.2 של RFC 3454 (רווחים לא-אסקי) ממופה ל-U+0020, וטבלה B.1 (תווים שמקובל למפות ללא כלום) נמחקת לחלוטין. נורמליזציה ל-Unicode NFKC עוקבת, שהיא השלב שמקפל תווי תאימות ורצפים משולבים. אז בדיקת הפלט האסור דוחה כל דבר בטבלאות C.2.1 עד C.9. לבסוף, כלל הדו-כיווניות מ-RFC 3454 סעיף 6 מוחל על המחרוזת המנורמלת

PDFlibPas מיישמת את כל הפרופיל ביחידת PDFlibSASLprep, שחושפת נקודת כניסה יחידה. PLSASLprepPassword לוקחת את הסיסמה הגולמית, כותבת את הצורה המוכנה לפרמטר var, ומחזירה False כשהסיסמה חייבת להידחות. הפונקציה מכוונת בכוונה להיות מוחלטת בנתיב השמח: סיסמה אסקי-בלבד חוזרת זהה בייט-לבייט, כך ששום דבר בפריסות קיימות לא משתנה

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 שהטבלאות לא פותרות

נקודת קוד אחת נופלת בשתי טבלאות RFC 3454 בו-זמנית, ושתי הטבלאות חלוקות. ZERO WIDTH SPACE (U+200B) נופל בתוך טווח C.1.2 מ-U+2000 עד U+200B, שם הכלל אומר למפות אותו ל-U+0020, והוא גם נופל בתוך טווח B.1 מ-U+200B עד U+200D, שם הכלל אומר למחוק אותו. קרא את שלב המיפוי בכל סדר ותקבל בייטים שונים מאותה סיסמה: a+U+200B+b מוכן ל-a b תחת C.1.2 ול-ab תחת B.1. RFC 4013 מזכיר את שתי הטבלאות ולא אומר מי מנצח, כך שזו עמימות אמיתית בתקן ולא שגיאת קריאה. PDFlibPas בודקת חברות ב-C.1.2 קודם ולכן ממפה את U+200B לרווח, וזו ההתנהגות שבה יישומי stringprep נפוצים אחרים התיישבו; התאמה אליהם היא הדבר היחיד שחשוב כאן, כי המטרה היא הסכמת בייטים עם כל קורא שהלקוח נקרה בו

קריאת קבצים ישנים: מוכן קודם, גולמי שני

התיקון יוצר את בעיית התאימות שלו עצמו. כל קובץ AES-256 שנכתב לפני השינוי גיבב את הסיסמה הגולמית ב-UTF-8, כך שהפיכת הקורא לתואם באורח קפדני הייתה נועלת לקוחות מחוץ לארכיונים שלהם עצמם. PDFlibPas פותרת זאת בצד הקריאה על ידי ניסיון שני מועמדים בסדר. TPDFDocument.SetPassword בונה רשימת מועמדים שמתחילה בצורה המוכנה ונופלת חזרה לצורה הגולמית, והיא מוסיפה את הרשומה המוכנה רק כשהמסמך הוא באמת AES-256 ושתי הצורות שונות. עבור סיסמה אסקי הצורות זהות, הרשימה מחזיקה רשומה אחת, ועלות כל המנגנון היא השוואת מחרוזת בודדת. 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 חדש מקבל את הבייטים התואמים

אילו סיסמאות נדחות לחלוטין, ומהי שגיאה 604?

SASLprep יכולה לדחות סיסמה לחלוטין, וכשהיא עושה זאת, ההצפנה חייבת להיכשל בקול רם ולא להחליף בשקט משהו אחר. Encrypt ו-EncryptFile מכינות גם את סיסמת הבעלים וגם את סיסמת המשתמש בכל פעם ש-Strength הוא 3 או 4, מחזירות 0 בדחייה, וקובעות את LastErrorCode ל-PDFLIB_ERROR_PASSWORD_SASLPREP, שהוא 604. שתי משפחות קלט מפעילות את זה. טבלאות הפלט האסור דוחות תווי בקרה (C.2.1 ו-C.2.2), נקודות קוד לשימוש-פרטי (C.3), לא-תווים (C.4), surrogates בודדים (C.5), U+FFFD (C.6), תווי תיאור אידאוגרפיים (C.7), וטווחי בקרת-תצוגה ותיוג (C.8 ו-C.9). בנפרד, כלל הדו-כיווניות של RFC 3454 סעיף 6 דוחה כל מחרוזת שמכילה תו RandALCat מטבלה D.1 אלא אם המחרוזת גם מתחילה וגם מסתיימת באחד ואינה מכילה אף אות משמאל-לימין כלל

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;

כלל הדו-כיווניות הזה הוא זה שיפתיע את דלפק התמיכה שלך. סיסמה בערבית או בעברית שמסתיימת בספרה מערבית, או אחת עם אות לטינית תועה באמצע, נדחית על ידי התקן למרות שהיא נראית סבירה לחלוטין בשדה הקלט. הצג את 604 כהודעה על תווי הסיסמה, לא ככשל הצפנה גנרי, אחרת מישהו יבזבז אחר צהריים בחיפוש באג בגזירת המפתח שלך

מגבלות כנות: NFKC, LCat קירובי, ומלכודת דלפי אחת

שני חלקים של המימוש הם קירובים, וראוי שיוצהרו במפורש ולא ייקברו. נורמליזציית NFKC מבוצעת על ידי ה-API של חלונות NormalizeString, נטען דינמית מ-Normaliz.dll. כשהספרייה הזו לא זמינה, המחרוזת הממופה משמשת ללא נרמול, מה שאומר ששלבי המיפוי והאיסור עדיין רצים אבל קיפול התאימות לא. בפועל ה-DLL שולח עם כל שחרור חלונות מאז Vista, כך שהנתיב המוגבל הוא עניין של לפני-Vista ולא-חלונות ולא עניין חי, אבל סיסמה שמסתמכת על קיפול NFKC הייתה מפיקה שם בייטים שונים וזה סטייה אמיתית, גם אם רחוקה. בדיקת הדו-כיווניות היא הקירוב השני: זיהוי תווי LCat משתמש בטווחי אותיות נפוצים במקום בטבלה המלאה D.2 של RFC 3454, וכיוון השגיאה הזו הוא מה שהופך אותה לקבילה. תו LCat שהוחמץ יכול רק לגרום לכלל הדו-כיווניות לעבור במקום שבו התקן היה דוחה, לעולם לא ההפך, והוא אף פעם לא נוגע בשלבי המיפוי או הנרמול, כך שרצף הבייטים המוכן של סיסמה שהתקבלה נשאר ללא שינוי. הסיכון השיורי הוא לכן סטיית מדיניות ולא סטיית בייטים: סיסמה בכתב אקזוטי שיישום קפדני יותר היה מסרב לקבל כלל. כל סיסמה ששני הצדדים מקבלים מתגבבת זהה, וזו התכונה שאינטראופרביליות באמת תלויה בה

לבסוף, מלכודת תחביר דלפי שעולה שעה אם לא נתקלת בה קודם. כשפונקציה מחזירה סוג פרוצדורלי, השמה שלה בלי סוגריים לא קוראת לה. המהדר קורא את Proc := GetNormalizeProc; כלקיחת הכתובת של GetNormalizeProc עצמה, ואז מדווח E2009 עם התלונה הלא-מועילה שמוסכמות הקריאה שונות, כי הגישה משתמשת במוסכמה הברירת-מחדל בעוד סוג ה-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 עבור דלפי ו-C++Builder, שעמוד המוצר שלה נושא את הפניית ההצפנה המלאה ואת טבלת קודי השגיאה השלמה