PDF מוצפן ב-AES-256 עם סיסמה לא-אסקי נפתח בתוכנית שכתבה אותו ובשום מקום אחר. הסיבה כמעט תמיד היא שלב הכנה חסר: תקן ISO 32000-2 §7.6.4.3.3 דורש שהסיסמה תעובד עם פרופיל SASLprep של stringprep לפני שהיא מקודדת ב-UTF-8 ומתגבבת. PDF Library for Delphi, ספריית ה-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 מוחל על המחרוזת המנורמלת
PDF Library for Delphi מיישמת את כל הפרופיל ביחידת PDFlibSASLprep, שחושפת נקודת כניסה יחידה. PLSASLprepPassword לוקחת את הסיסמה הגולמית, כותבת את הצורה המוכנה לפרמטר var, ומחזירה False כשהסיסמה חייבת להידחות. הפונקציה מכוונת בכוונה להיות מוחלטת בנתיב השמח: סיסמה אסקי-בלבד חוזרת זהה בייט-לבייט, כך ששום דבר בפריסות קיימות לא משתנה
uses
PDFlibSASLprep;
var
Prepared: WideString;
begin
// RFC 4013: מיפוי, ואז NFKC, ואז פלט אסור, ואז כלל הדו-כיווניות
PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared); // -> 'IX' B.1 מוחקת SOFT HYPHEN
PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared); // -> 'a b' C.1.2 ממפה NBSP ל-U+0020
PLSASLprepPassword(WideString(WideChar($00AA)), Prepared); // -> 'a' NFKC מקפלת ORDINAL INDICATOR
PLSASLprepPassword(WideString(WideChar($2168)), Prepared); // -> 'IX' NFKC מקפלת ROMAN NUMERAL NINE
PLSASLprepPassword('user', Prepared); // -> 'user' ASCII אף פעם לא נוגעים בו
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 מזכיר את שתי הטבלאות ולא אומר מי מנצח, כך שזו עמימות אמיתית בתקן ולא שגיאת קריאה. PDF Library for Delphi בודקת חברות ב-C.1.2 קודם ולכן ממפה את U+200B לרווח, וזו ההתנהגות שבה יישומי stringprep נפוצים אחרים התיישבו; התאמה אליהם היא הדבר היחיד שחשוב כאן, כי המטרה היא הסכמת בייטים עם כל קורא שהלקוח נקרה בו
קריאת קבצים ישנים: מוכן קודם, גולמי שני
התיקון יוצר את בעיית התאימות שלו עצמו. כל קובץ AES-256 שנכתב לפני השינוי גיבב את הסיסמה הגולמית ב-UTF-8, כך שהפיכת הקורא לתואם באורח קפדני הייתה נועלת לקוחות מחוץ לארכיונים שלהם עצמם. PDF Library for Delphi פותרת זאת בצד הקריאה על ידי ניסיון שני מועמדים בסדר. 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');
// עוצמה 3 ו-4 הם שני ערכי AES-256; שניהם מוכנים לפני הגיבוב
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' זה מה ש-SASLprep הפיקה ומה שכל קורא תואם מחשב,
// כך שהצורה הפשוטה של ASCII פותחת קובץ שנוצר עם צורת המקף-הרך
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 הוא תו בקרה מ-C.2.1, כך שההכנה דוחה את הסיסמה
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; // טוען את Normaliz.dll בשימוש הראשון
...
var
Proc: TNormalizeString;
begin
// Proc := GetNormalizeProc; // E2009: נקרא כ-@GetNormalizeProc, המוסכמות שונות
Proc := GetNormalizeProc(); // נכון: קורא לפונקציית הגישה ומקצה את התוצאה שלה
if not Assigned(Proc) then
Exit; // NFKC לא זמין, המחרוזת הממופה משמשת כמות שהיא
end;
הכנת סיסמה היא אחד מהפרטים האלה שאף פעם לא מופיע ברשימת תכונות ומחליט האם מסמך מוצפן שורד מגע עם לקוח ב-locale אחר. נקודות הכניסה Encrypt, EncryptFile, DecryptFile ו-SetPassword המתוארות כאן הן חלק מ-losLab PDF Developer Library Pascal Edition עבור דלפי ו-C++Builder, שעמוד המוצר שלה נושא את הפניית ההצפנה המלאה ואת טבלת קודי השגיאה השלמה