מאמר טכני

ניסיון-חוזר של סיסמאות PDF מוצפן ב-Delphi עם PDF Library for Delphi

PDF Library for Delphi מנסה מחדש סיסמה שגויה על PDF מוצפן על ידי השלכת ה-TPDFDocument שהרגע נכשל ויצירת אחד חדש לגמרי עבור הניסיון הבא, מונע על ידי callback ‏OnPassword (‏TPDFlibPasswordEvent) שרץ עד לשש-עשרה ניסיונות לפני שהוא מוותר. זו סטייה מכוונת מהאינסטינקט שרוב מפתחי ה-Delphi פונים אליו קודם: שמור על אובייקט המסמך שכבר יושב בזיכרון, הזן לו סיסמה מתוקנת, וטען שוב במקום במקום להתחיל מחדש מכלום. לולאת הניסיון-החוזר של PDF Library for Delphi, שנוספה ב-v3.245.0, נוקטת בעמדה ההפוכה, מסיבות ספציפיות למה שניסיון-סיסמה כושל משאיר מאחוריו. התרחיש מאחורי זה רגיל מספיק שרוב אפליקציות ה-Delphi כבדות-המסמכים נתקלות בו בסופו של דבר: מסך קליטה מקבל PDF, טריילר מוצפן כופה תיבת דו-שיח סיסמה, המפעיל מקליד שגוי, ותיבת הדו-שיח מופיעה מחדש לניסיון שני. שום דבר בחוויית המשתמש הזו לא חריג, כך שהקוד מאחוריה חייב לקבל יותר מסיסמת-מועמד אחת עבור אותו קובץ, והוא חייב לעשות זאת בבטחה, בלי לדלוף מצב מהניסיון הנדחה לתוך זה שבא אחריו

למה אי אפשר סתם לנסות שוב על אותו אובייקט מסמך?

שימוש חוזר ב-TPDFDocument על פני ניסיונות סיסמה לא עובד, משום שניסיון כושל כבר פירק את האובייקט ההוא פנימית במקום להשאיר אותו במצב מושהה, ניתן-להמשך. פתיחת PDF מוצפן אומרת פענוח טבלת ההפניה-הצולבת, בניית קורא מעל המקור הבסיסי, ובניית מטפל-הצפנה מכל סיסמה שנמסרה, כל זה עוד לפני ש-PDF Library for Delphi בכלל יכולה לבדוק אם הסיסמה ההיא נכונה. כאשר הסיסמה מתבררת שגויה, שגרת הטעינה הפנימית של המסמך מנקה את הקורא, טבלת ההפניה-הצולבת, ומטפל-ההצפנה כחלק מהיכשלות-החוצה, בדיוק כפי שהיא צריכה, מה שאומר שאין מפענח חצי-בנוי שיושב שם וממתין לסיסמה מתוקנת בקריאה שנייה. הנע את אותו אובייקט דרך עוד ניסיון-טעינה בכל זאת ומצב-הכשל הוא בדיוק הסוג שעלוב לנפות: שגיאה עולה על פני השטח ממצב פנימי שנבנה עבור פענוח שונה, שכבר נכשל, ללא שום דבר בה שמצביע בבירור בחזרה אל הסיסמה שלוש קריאות במעלה-הזרם. PDF Library for Delphi נמנעת מכל מחלקת-הבעיה הזו על ידי כך שהיא אף פעם לא מנסה לשחזר אובייקט מסמך ברגע שהוא נכשל בפתיחה; כל ניסיון מקבל מסמך שאף פעם לא ראה סיסמה שגויה, קורא וטבלת הפניה-צולבת כלולים

איך callback ‏OnPassword מבקש את הסיסמה הבאה?

TPDFlibPasswordEvent הוא סוג ה-callback ש-PDF Library for Delphi מפעילה דרך TPDFlib.LoadFromFile, ‏LoadFromStream, ו-LoadFromString בכל פעם שהסיסמה שהרגע נוסתה מתבררת שגויה, והוא מוסר ל-handler שלושה דברים: איזה ניסיון עומד לרוץ, פרמטר Password לכתוב-מעל עם המועמד הבא, ודגל Retry שברירת המחדל שלו false

TPDFlibPasswordEvent = procedure(Sender: TObject; AttemptNumber: Integer;
  var Password: WideString; var Retry: Boolean) of object;

property OnPassword: TPDFlibPasswordEvent read FOnPassword write FOnPassword;

הסיסמה שנמסרה לתוך קריאת ה-LoadFromFile המקורית נספרת כניסיון אחד, כך שבפעם הראשונה ש-OnPassword בכלל מופעל, AttemptNumber מגיע כ-2. השאר את Retry בלתי-מוגדר וה-load נכשל נקי עם LastErrorCode 404; הגדר אותו true ו-PDF Library for Delphi מנסה שוב עם מה שה-handler הרגע כתב לתוך Password

בתוך לולאת הניסיון-החוזר: TPDFDocument חדש לכל ניסיון

פנימית, PDF Library for Delphi עונה על שאלת-מחזור-חיי-האובייקט באותו אופן עבור LoadFromFile, ‏LoadFromStream, ו-LoadFromString: כל ניסיון, כולל הראשון, בונה TPDFDocument טרי, מריץ אותו דרך רצף הפתיחה המלא עם איזו סיסמה שהניסיון ההוא משתמש בה, ורק שומר על האובייקט אם הסיסמה מאומתת. ה-TPDFDocument של ניסיון שנדחה משוחרר מיד, לוקח איתו את הקורא, טבלת ההפניה-הצולבת, ומטפל-ההצפנה שלו, והניסיון הבא מתחיל מחדש עם אובייקט ללא היסטוריה בכלל

תרשים זרימה של PDF Library for Delphi ללולאת ניסיונות החזרה של הסיסמה, שבה כל ניסיון בונה TPDFDocument טרי, ניסיון שנדחה משחרר את ה-reader, טבלת ה-cross-reference ומנהל ה-crypt שלו, וה-callback של OnPassword מחליט אם ירוץ ניסיון נוסף או שהטעינה תיכשל עם LastErrorCode 404
מועמד שנכשל לעולם לא שורד את הלולאה: המסמך שלו משוחרר כשמצב ה-parser עדיין חצי-בנוי. הגדרת Retry כותבת את המועמד הבא אל Password וחוזרת עם אובייקט שמעולם לא ראה סיסמה שגויה
// קטע פשוט מתוך LoadFromFile: כל ניסיון מקבל
// מסמך שאף פעם לא ראה סיסמה שנדחתה קודם. FileName,
// AttemptNumber ו-AttemptPassword מגיעים מהמתודה העוטפת.
Var
  Doc: TPDFDocument;
  LoadResult: TPLLoadResult;
  Success: Boolean;
Begin
  Success := False;
  Repeat
    Doc := TPDFDocument.Create;
    Doc.DecodeMode := FDefaultDecodeMode;
    Try
      LoadResult := Doc.LoadFromFile(FileName, AttemptPassword);
      Success := LoadResult = lrOkay;
      if Success then
      begin
        FDocs.Add(Doc);            // מוסר את המסמך המאומת ל
        Doc := nil;                 // אוסף הקוד הקורא; דלג על ה-Free שלמטה
      end;
    Finally
      Doc.Free;                     // הקורא, טבלת ההפניה-הצולבת
    End;                            // ומטפל-ההצפנה של ניסיון שנדחה נהרסים כאן בדיוק
    if Success or (LoadResult <> lrWrongPassword) then
      Break;                        // הצלחה, או כישלון שאינו סיסמה: עצור
    Inc(AttemptNumber);
  Until not RequestPasswordRetry(AttemptNumber, AttemptPassword);
End;

שורת ה-Doc := nil ההיא ממש לפני בלוק ה-Finally היא כל חוזה מחזור-חיי-האובייקט בהוראה אחת. מסמך שנכשל נושא את מצב-המפענח החצי-בנוי שלו לקבר איתו, בעיצוב, ומסמך שמצליח הוא היחיד שאי-פעם נוסף ל-FDocs, האוסף ש-TPDFlib שומרת עבור כל מסמך שהקוד הקורא פתח. שום דבר מניסיון שנדחה לא נראה מחוץ ללולאת הניסיון-החוזר: לא קורא חצי-מאותחל, לא ספירת-עמודים מיושנת, לא מטפל-הצפנה שנבנה מהמפתח הלא-נכון

כמה פעמים PDF Library for Delphi תנסה שוב סיסמה שגויה?

PDF Library for Delphi מאפשרת שש-עשרה ניסיונות כוללים מול קריאת LoadFromFile, ‏LoadFromStream, או LoadFromString בודדת, סופרת את הסיסמה שנמסרה לתוך הקריאה עצמה כניסיון אחד. ‏OnPassword אף פעם לא מופעל אלא עבור ניסיונות שתיים עד שש-עשרה, מה שכובל את ה-callback לחמש-עשרה הפעלות; בקש ניסיון שבע-עשרה ו-PDF Library for Delphi מסרבת בלי אפילו לקרוא ל-handler. השאר Retry בברירת המחדל שלו false בכל נקודה, או מצה את כל שש-עשרה הניסיונות בלי סיסמה נכונה, ו-LoadFromFile מחזירה 0 עם LastErrorCode מוגדר ל-404, הקוד של PDF Library for Delphi עבור סיסמה שנדחתה. התקרה קיימת מסיבות מעבר לסדר: לולאת ניסיון-חוזר בלתי-מוגבלת היא דרך קלה להפוך סיסמה שהוקלדה שגוי בטעות למניעת-שירות מקרית נגד איזה תהליכון שמריץ את הטעינה, במיוחד ברגע ש-handler מחווט למשהו אוטומטי, כמו רשימת סיסמאות שנראו קודם, במקום בן-אדם שלוחץ דרך תיבת דו-שיח. PDF Library for Delphi גם מכבדת Abort שנקרא על מופע ה-TPDFlib מתוך ה-handler, שכן Sender מגיע כאותו אובייקט עצמו, שימושי מאחורי כפתור Cancel בתיבת דו-שיח סיסמה, ועוצרת את לולאת הניסיון-החוזר בבדיקה הבאה ללא קשר למה ש-Retry הוגדר. טעינה שנכשלת מסיבה שאינה סיסמה שגויה, טבלת הפניה-צולבת פגומה למשל, אף פעם לא נכנסת ללולאת הניסיון-החוזר בכלל: PDF Library for Delphi מדווחת LastErrorCode 401 ועוצרת אחרי הניסיון הראשון, משום ששום מספר של ניחושי-סיסמה לא מתקן קובץ שבור מבנית

PDF Library for Delphi: מסלול ניסיונות מאחד עד שישה עשר שמציג את OnPassword יורה מהניסיון השני והלאה, את הניסיון השבעה עשר שנדחה מבלי להפעיל את ה-handler, ואת התוצאות הנפרדות להצלחה, LastErrorCode 404 ו-LastErrorCode 401
רק הסיסמה שנמסרת לקריאת הטעינה עצמה נחשבת ניסיון ראשון, וה-callback לעולם אינו רץ יותר מחמש-עשרה פעמים לכל טעינה. קובץ שבור מבחינה מבנית עוקף את הלולאה לגמרי ומדווח 401 אחרי ניסיון אחד

האם לולאת הניסיון-החוזר עובדת אותו דבר עבור קבצים, זרמים, ומחרוזות?

ה-callback ‏OnPassword ותקרת שש-העשרה-הניסיונות מתנהגים זהה על פני LoadFromFile, ‏LoadFromStream, ו-LoadFromString, אף על פי ששלוש נקודות הכניסה מחזיקות במקור שלהן אחרת בין ניסיונות. נתיב קובץ זול לבקר מחדש, שכן כל ניסיון פשוט פותח מחדש את הקובץ הנקוב, ומקור-מחרוזת כבר יושב בזיכרון כעותק של הקוד הקורא עצמו, כך שאף אחד מהם לא זקוק לעזרה מהקוד הקורא בין ניסיונות. זרם שנמסר-על-ידי-הקוד-הקורא הוא המקרה האחד ששווה לעצור עליו: LoadFromStream מחזירה את הזרם ההוא לעמדה אפס ומעתיקה אותו פנימית לפני ניסיון-הפענוח הראשון, כך שכל ניסיון עוקב, וה-TPDFDocument שנבנה טרי מאחוריו, מתנגן מחדש מהעותק הפנימי ההוא במקום מכל מקום שפענוח כושל השאיר את מיקום הזרם. מסור ל-PDF Library for Delphi TFileStream או TMemoryStream עבור מסמך מוגן-סיסמה ואין צורך לגלול אותו אחורה בין ניסיונות-חוזרים; PDF Library for Delphi כבר מתחשבת בעמדה שניסיון ראשון, כושל, אולי הזיז

שילוב ניסיון-חוזר של סיסמה לתוך מסך קליטת מסמכים

זרימת עבודה של קליטת מסמכים היא הבית הטבעי עבור ה-callback הזה, משום שזו בדיוק צורת הבעיה ש-OnPassword נבנתה כדי לפתור: קובץ מגיע מחוץ לאפליקציה, הסיסמה שלו לא ידועה בוודאות מראש, והאדם שמספק מועמדים זקוק ליותר מניחוש אחד בלי שהקוד שמסביב יכתוב לולאת ניסיון-חוזר משלו סביב LoadFromFile

procedure TIntakeForm.SupplyPassword(Sender: TObject; AttemptNumber: Integer;
  var Password: WideString; var Retry: Boolean);
var
  Typed: string;
begin
  // AttemptNumber סופר מ-2: הסיסמה שכבר נוסתה הייתה הניסיון 1.
  Typed := '';
  Retry := InputQuery('Password required',
    Format('Attempt %d of 16 - enter the document password', [AttemptNumber]), Typed);
  if Retry then
    Password := Typed;
  // Retry הוא False כאשר המפעיל מבטל, מה שמשאיר
  // את LastErrorCode על 404 עבור הקוד הקורא לדווח.
end;
procedure TIntakeForm.LoadInboundDocument;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.OnPassword := SupplyPassword;
    if Lib.LoadFromFile('inbound-invoice.pdf', '') = 1 then
      RegisterIntakeDocument(Lib)        // רק מסמך מאומת מגיע לכאן
    else
      LogRejectedIntake('inbound-invoice.pdf', Lib.LastErrorCode);
  finally
    Lib.Free;
  end;
end;

‏RegisterIntakeDocument אף פעם לא מקבלת Lib אלא ברגע ש-LoadFromFile החזירה 1, כלומר סיסמה כלשהי בחילופין ההוא בפועל אומתה מול מטפל-ההצפנה של הקובץ; ניסיון שנדחה אף פעם לא מגיע לשורה ההיא, וגם לא מסמך חצי-פתוח. מה שבא אחר-כך, ברגע שמסמך כזה מאושר כפתוח, שווה מבט שני על הגדרות ההגנה שלו במקום הנחה שהסיסמה שעבדה היא כל סיפור האבטחה: ביקורת מה שמילון ה-/Encrypt של מסמך בפועל מצהיר מכסה קריאת האלגוריתם, הרוויזיה, וסיביות ההרשאה ש-PDF Library for Delphi חושפת ברגע שקובץ כזה נטען

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

‏OnPassword ולולאת הניסיון-החוזר שמאחוריה הם חלק מספריית ה-PDF PDF Library for Delphi הסטנדרטית עבור Delphi ו-C++Builder, זמינה בכל מקום ש-LoadFromFile, ‏LoadFromStream, או LoadFromString כבר נמצאים, ללא מודול נפרד או רמת רישיון נדרשת עבור מסמך שסתם זקוק לניחוש שני לסיסמה שלו