מאמר טכני

פלט PDF שניתן לשחזור בדלפי: שמירות זהות בית-בבית

רכיב HotPDF ל-Delphi מפיק פלט PDF זהה בית-בבית בין שמירות כשהמאפיין ReproducibleOutput הוא True: הוא מקבע את /CreationDate וה-/ModDate של ה-Info לתאריך קבוע, מחליף את מזהה המסמך שנגזר משעון הקיר ב-hash שמבוסס זרע או תוכן, ממיר לקבועים כל בית אקראי שנתיבי ההצפנה ב-AES היו שואבים אחרת, וממיין כל מילון שהוא מסריאל. הדגל קיים בשביל מערכי רגרסיה והשוואת תוצרי בנייה, ולא בשביל מסמכי פרודקשן, והסיבות לגבול הזה הן החלק המעניין. התרחיש שמניע את התכונה הוא בדיקת golden file. אתם מרנדרים חשבונית, מבצעים commit ל-PDF, ומאמתים שהבנייה של מחר מפיקה את אותם בתים. זה אף פעם לא קורה. הקובץ נפתח היטב בכל מציג, הטקסט זהה, עץ העמודים זהה, וה-diff עדיין מואר בארבעה או חמישה מקומות. כל מי שניסה להעמיד מחולל PDF תחת בדיקת רגרסיה ברמת הבתים נתקל בקיר הזה, והתיקון אינו "להסיר את חותמות הזמן" אלא חשבונאות מדויקת של כל מקום שבו הכותב נועץ במשהו שאינו המסמך עצמו

למה שתי שמירות של אותו PDF נבדלות?

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

  • השעון. מילון ה-Info נושא את /CreationDate ואת /ModDate (ISO 32000-1 §14.3.3, טבלה 317) כמחרוזות D:YYYYMMDDHHmmSS עם סיומת אזור זמן (§7.9.4), וחבילת ה-XMP חוזרת על אותו רגע בתור xmp:CreateDate ו-xmp:ModifyDate. HotPDF חותמת את שניהם מ-FCreationDate, שהבנאי מאתחל ל-Now, כך ששתי השמירות נבדלות בשנייה שבה נכתבו
  • המזהה. מערך ה-/ID ב-trailer (ISO 32000-1 §14.4) מחזיק מזהה קבוע ומזהה שינוי. המתכון המוגדר כברירת מחדל של HotPDF עושה hash לשם הקובץ יחד עם הזמן הנוכחי עד למילישנייה עבור האיבר הראשון, ועושה hash לזה ועוד GetTickCount עבור השני. שני מזהים, שני ערכים טריים בכל הרצה
  • הבתים האקראיים. אבטחה תקנית תלויה במזהה ובאקראיות אמיתית. ב-AES-256 מפתח הצפנת הקובץ, ה-salts של האימות והמפתח, וכל וקטור אתחול של CBC נשאבים ממקור האקראיות של המערכת (ISO 32000-2 §7.6.4.4.7 דורש salts אקראיים). מכיוון ש-/U, /UE, /O ו-/OE מחושבים כולם מהבתים האלה, מסמך מוצפן משתנה כולו גם כשהטקסט הגלוי לא. האלגוריתמים הישנים יותר מקפלים את האיבר הראשון של /ID לתוך המפתח (ISO 32000-1 §7.6.3.3, §7.6.3.4), כך שמזהה טרי לבדו מספיק כדי להחליף את מפתח הקובץ
  • הסדר. מילון PDF הוא מיפוי בלי סדר, וכותב שעובר על הרשימה שבזיכרון פולט מפתחות לפי סדר ההכנסה. כל נתיב קוד שבונה מילון משאבים ברצף אחר, או מסמך טעון שנפרסר מפריסה אחרת, מפיק קובץ חוקי אבל שונה טקסטואלית
ארבעת מקורות האנטרופיה שגורמים לשתי שמירות של מסמך אחד ב-HotPDF להיבדל: FCreationDate שנחתם מ-Now מזין את תאריכי ה-D: ואת חבילת ה-XMP, ה-/ID ב-trailer עושה hash לשם הקובץ, לשעון ול-GetTickCount, AES שואב חומר מפתח ממקור האקראיות של המערכת, והמילונים מסריאלים לפי סדר ההכנסה בזיכרון
כל מקור לגיטימי בפני עצמו ו-ISO 32000-1 רוצה שהם יהיו שם, אבל יחד הם הופכים את הקובץ לפונקציה של מתי ואיפה הוא נכתב ולא של מה שהוא מכיל

מה מקבע ReproducibleOutput?

קביעה של ReproducibleOutput := True לפני BeginDoc או לפני SaveLoadedDocument מחליפה כל אחד מארבעת המקורות בערך קבוע, והיא עושה זאת באותם נתיבי קוד שהיו פונים אחרת לשעון או למחולל האקראי, כך שאין צורך במעבר ניקוי נפרד. שימו לב למה שחסר ברשימה שלמעלה: התוכן. פונטים, זרמי עמודים, נתוני תמונות וטבלת ה-cross-reference הם כבר דטרמיניסטיים לאותו קלט; הרעש יושב כולו במטא-נתונים ובשכבת האבטחה, וזו הסיבה שמאפיין ממוקד אחד יכול להסיר אותו. המאפיין מוגדר כברירת מחדל ל-False ושום דבר בספרייה לא מדליק אותו בשבילכם

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // לפני BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

בתוך BeginDoc הענף המשוחזר משמה FCreationDate := EncodeDate(2026, 1, 1) ומזרע את מזהה המסמך ב-MD5CalcString('HotPDF-reproducible-seed') במקום ה-digest של שם הקובץ והשעון. ההשמה הבודדת הזו מכסה גם את שני תאריכי ה-Info וגם את שני תאריכי ה-XMP, כי כל הארבעה נוצרים מאותו שדה. כשהקובץ נכתב לבסוף, BuildDocumentIdentifiers מבקשת מ-ComputeCanonicalDocumentIdentifier את מזהה ה-trailer: היא מייצאת את כל גרף האובייקטים בסדר קנוני, מאפסת את הספרות של כל מחרוזת תאריך D: שהיא מוצאת כדי שחותמות הזמן לא ידלפו חזרה פנימה דרך ה-hash, ולוקחת את ה-MD5 של התוצאה. שני האיברים של /ID מקבלים את הערך הזה. אותו מזהה נגזר מהתוכן משמש כשמסמך טעון מוצפן בלי לעבור בכלל דרך BeginDoc, וזה המקרה של ActivateProtection על קובץ שפתחתם עם LoadFromFile

הבתים האקראיים הם ההחלפה הכי פחות מובנת מאליה. שגרת המפתח של AES-256 עוטפת את מקור האקראיות שלה ב-helper מקומי שתחת הדגל קורא ל-FillChar(P^, Count, $5A) עבור מפתח הצפנת הקובץ בן 32 הבתים ועבור כל salt בן 8 בתים, ומצפיני המחרוזות והזרמים ב-AES-128 וב-AES-256 עוברים מ-AESGenerateRandomIV ל-AESGenerateStaticIV, שממלא את וקטור האתחול ב-14 * (1 + I) עבור משבצת I. כשהמפתח, ה-salts והוקטורים כולם קבועים, /U, /UE, /O, /OE וכל זרם מוצפן יוצאים זהים בהרצה השנייה. ולבסוף, SaveToStream מדליקה את DeterministicDictionaryOrder בכל פעם שהדגל המשוחזר דלוק, והסריאליזר עושה אז insertion sort לכל מילון לפי הבתים הגולמיים של שמות המפתחות שלו, קידומת קצרה יותר קודם, עם האינדקס המקורי כשובר שוויון. זה אותו סדר שבו משתמש כותב האבחון, המתואר במאמר על עריכת PDF ביד ותיקונו אחר כך; הדגל המשוחזר שואל רק את הסדר, ולא את שאר הפריסה כטקסט גלוי של אותו כותב

מה מקבע ReproducibleOutput ב-HotPDF: תאריך היצירה הופך ל-EncodeDate 2026, 1, 1, מזהה ה-trailer מגיע מ-ComputeCanonicalDocumentIdentifier על הגרף הקנוני כשספרות ה-D: מאופסות, מפתחות ה-AES וה-salts מתמלאים בבתי $5A ו-AESGenerateStaticIV ממלא כל משבצת, ו-DeterministicDictionaryOrder ממיין כל מילון
ההחלפות רצות באותם נתיבי קוד שהיו פונים אחרת לשעון או למחולל האקראי, כך שאין צורך במעבר ניקוי נפרד ושני האיברים של /ID מקבלים את אותו ערך נגזר תוכן

למה התאריך הקבוע עדיין דלף את שעון הקיר?

התיקון ב-v2.752.2 קיים כי התאריך הקבוע הוחלט במקור בבנאי, והבנאי לא יכול לדעת מאפיין שהקורא עוד לא קבע. רצף הקריאות הרגיל הוא Create, אחר כך ReproducibleOutput := True, אחר כך BeginDoc. בזמן הבנייה FReproducibleOutput עדיין False, כך ש-FCreationDate קיבל את Now ושמר עליו. המזהה והבתים האקראיים כן קובעו נכון, כך ששני הקבצים הסכימו כמעט בכל מקום ולא הסכימו בדיוק בשתי מחרוזות תאריך ובשני שדות XMP. העברת ההשמה לענף המשוחזר של BeginDoc, לצד המזהה המזורע, שמה את ההחלטה בנקודה שבה למאפיין יש את ערכו הסופי

בדיקת הרגרסיה שהחמיצה את זה שווה יותר מהתיקון. שתי שמירות שרצות בתוך אותה שנייה של שעון הקיר כותבות במקרה את אותה מחרוזת D:, והשוואת הבתים עוברת עבור באג שנכשל בכל מכונה איטית יותר. הבדיקה המתוקנת ישנה 1100 מ"ש בין שתי השמירות כך שחותם הזמן של ה-PDF מובטח לחצות גבול שנייה, מריצה את המקרה עבור פלט רגיל, AES-128 ו-AES-256 עם סיסמאות אמיתיות בשני הווריאנטים המוצפנים, ומשווה את שני המאגרים עם CompareMem, ומדווחת את ההיסט השונה הראשון בכשל כך שה-diff מצביע על אובייקט ספציפי ולא על קובץ שלם. השוואת בתים מוכיחה דטרמיניזם ושום דבר אחר, ולכן החזיקו בדיקה נפרדת שטוענת את הפלט המוצפן עם סיסמת המשתמש וקוראת מספר עמודים; שינוי שהופך את הקובץ ליציב ובלתי קריא בעת ובעונה אחת לא צריך לחמוק על סמך diff ירוק

function SaveOnce(const Target: string): TBytes;
var
  Pdf: THotPDF;
  Stream: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.CryptKeyLength := aes256;
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

// בגוף הבדיקה
A := SaveOnce(PathA);
TThread.Sleep(1100);          // לכפות שנייה אחרת בחותם הזמן של ה-PDF
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
  'two saves under ReproducibleOutput must be byte-identical');

האם PDF מוצפן שניתן לשחזור עדיין מאובטח?

לא. מסמך שמוצפן תחת ReproducibleOutput אינו מוגן בשום מובן משמעותי, והדגל חייב להיות כבוי לכל דבר שיוצא מתיקיית הבדיקות. מפתח הצפנת הקובץ ב-AES-256 הוא שלושים ושניים בתים של $5A, ה-salts הם שמונה בתים של $5A, ווקטורי האתחול הולכים אחרי דפוס אריתמטי מפורסם. הסיסמה עדיין שומרת על עטיפות ה-/UE וה-/OE, אבל המפתח העטוף הוא קבוע, כך שכל מי שמכיר את הקבוע יכול לפענח כל זרם תוכן בלי סיסמה בכלל. העובדה שה-salts קבועים גם מסירה את הייחודיות לכל מסמך ש-ISO 32000-2 §7.6.4.4.7 נשען עליה כדי למנוע מסיסמאות זהות להפיק מחרוזות /U זהות בין קבצים. קראו את מאמר ההגדרה של AES-256 כדי לדעת מה מאפייני ההצפנה מבטיחים כשמקור האקראיות שלם; תחת הדגל המשוחזר ההבטחות האלה מושהות

הפשרה במזהה עדינה יותר. ISO 32000-1 §14.4 מתכוון שהאיבר השני של /ID ישתנה בכל שינוי כך שכלים יוכלו להבדיל קובץ מעודכן מאב הקדמון שלו, ושמירה משוחזרת כותבת את אותו ערך בשני המשבצות. מכיוון שהערך הזה הוא hash של גרף האובייקטים הקנוני, שני מסמכים עם תוכן שונה עדיין מקבלים מזהים שונים, וזה טוב יותר מקבוע. אבל הזרע שבו BeginDoc משתמשת לגזירת המפתח הוא אותה מחרוזת עבור כל מסמך בכל מכונה, וקורא שנשען על /ID כדי להבדיל קבצים, למשל cache של הערות או קובץ לוואי של נתוני טופס, יבלבל כל קובץ משוחזר שבמקרה מקבל את אותו hash

מה הדגל לא מכסה?

‏ReproducibleOutput מסירה את האנטרופיה שהכותב מכניס מעצמו; היא לא יכולה להסיר אנטרופיה שנכנסת דרך הסביבה או דרך נתיבי קוד שהיא לא שולטת בהם, ושלושה כאלה קל להיתקל בהם

  • סיומת אזור הזמן. _DateTimeToPdfDate מוסיפה את היסט ה-UTC המקומי, כך ש-D:20260101000000+08'00' על סוכן בנייה אחד ו-D:20260101000000-05'00' על אחר הם בתים שונים לאותו תאריך קבוע. השחזוריות מחזיקה בין הרצות על מכונה אחת, או בין מכונות שחולקות אזור זמן; קבעו את אזור הזמן של הסוכן אם קובצי ה-golden שלכם נוסעים
  • עדכונים מצטברים. SaveIncrementalUpdate מחשבת את מזהה השינוי שלה מנתיב היעד, מ-GetTickCount ומהזמן הנוכחי בלי ענף משוחזר, כי מקטע מצטבר הוא בהגדרה שינוי חדש. השוו כתיבות מלאות מחדש, ולא דלתאות מצורפות
  • קיצור הדרך של pass-through. SaveLoadedDocument מעתיקה בדרך כלל קובץ מקור שלא שונה ולא מוצפן בית-בבית במקום לסריאל אותו מחדש. הדגל המשוחזר מבטל את קיצור הדרך הזה וכופה כתיבה מלאה מחדש כדי שכללי הסדר והמזהה יחולו, מה שאומר ששמירה משוחזרת של קובץ טעון איטית מברירת המחדל והיא אף פעם לא העתקה של הקלט. עשו לה diff מול שמירה משוחזרת קודמת, ואף פעם לא מול המקור
איפה שמירות משוחזרות ב-HotPDF נעצרות: _DateTimeToPdfDate עדיין מוסיפה את היסט ה-UTC המקומי כך שקובצי golden נבדלים בין אזורי זמן, ל-SaveIncrementalUpdate אין ענף משוחזר כי דלתא היא שינוי חדש, וקיצור הדרך של pass-through מבוטל כך שקובץ טעון תמיד נכתב מחדש במלואו
השחזוריות מחזיקה בין הרצות על מכונה אחת או בין מכונות שחולקות אזור זמן, ושמירה משוחזרת צריכה לקבל diff מול שמירה משוחזרת קודמת, ואף פעם לא מול קלט המקור

עוד לקח מאותו שחרור, על מה בדיקה שעוברת מוכיחה ומה היא לא מוכיחה. קובץ בדיקה של PDF/X-6 קרא ל-CharProcs.DeleteValue('A'), ששחרר זרם glyph שהוחזק ישירות, ואז הכניס מחדש את אותו מצביע, ובנפרד מסר אובייקט ExtGState ישיר אחד גם למילון משאבים וגם ל-pattern. מאמת התאימות עבר לסירוגין על ה-use-after-free וה-double ownership האלה כי הוא קרא מה שהזיכרון המשוחרר במקרה החזיק. כשבדיקה מבנית מהבהבת, הסתכלו על הבעלות של קלט הבדיקה לפני שאתם מסתכלים על המאמת. פלט שניתן לשחזור הופך את המשמעת הזו לזולה יותר: ברגע ששתי שמירות זהות בית-בבית, המקור היחיד שנותר להבהוב הוא גרף האובייקטים עצמו, וdiff מבני מה-catalog ומטה ימצא אותו

המאפיינים ReproducibleOutput, DeterministicDictionaryOrder וההצפנה המתוארים כאן נשלחים ברכיב HotPDF ל-Delphi הרגיל עבור Delphi ו-C++Builder, ואותו דגל מניע גם את קורפוס הרגרסיה של הספרייה עצמה, כך שההתנהגות שמקבלים במערך בדיקות היא ההתנהגות שהרכיב נבדק איתה