מאמר טכני

אי-אפשר לטלא PDF מוצפן בשקט ב-Delphi

קח PDF חשבונית שכבר נושא הצפנת AES-256 ובקש מרכיב PDFium עבור Delphi ו-C++Builder (‏PDFiumPas) להטביע עליו PDF/A לשימור ארכיוני, או לחתום עליו עם PAdES, דרך עדכון הדרגתי ולא כתיבה-מחדש מלאה. הספרייה לא תגיע לשם על ידי טלאה של הבייטים המוצפנים ישירות: ששת מזריקי הסמנים של התאימות שלה מזהים רשומת /Encrypt קיימת ומעבירים את המקור בייט-לבייט ללא שינוי, וחותם ה-PAdES שלה מעלה חריגה במקום לפלוט חתימה שאף מאמת לא יקבל

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

מה ISO 32000-1 דורש כשאתה מעדכן PDF מוצפן?

‏ISO 32000-1 סעיף 7.5.6 דורש שהטריילר של עדכון הדרגתי יחזור על כל רשומה מהטריילר הקודם מלבד /Prev, וטבלה 15 מפרטת את /Encrypt בין הרשומות שטריילר יכול לשאת. השמט אותו מהטריילר החדש וקורא תואם אין לו סיבה לפקפק בהשמטה: הטריילר החדש ביותר סמכותי, כך שקורא שלא מוצא שם /Encrypt מחליט שכל הקובץ בלתי-מוצפן ומנסה לפענח את הגוף הישן יותר, העדיין-מוצפן, כבייטים פשוטים. שמור על /Encrypt בטריילר החדש אבל כתוב את האובייקטים של העדכון עצמם כטקסט-גלוי, והכשל פשוט זז צעד אחד מאוחר יותר: הקורא מזהה הצפנה נכון, מריץ כל אובייקט שהוא נוגע בו דרך הצופן של הקובץ, כולל החדשים שאף פעם לא הוצפנו מלכתחילה, ומקבל בחזרה רעש עבור תוכן שהיה קריא לגמרי לפני שפענוח נגע בו. כל אחת מהטעויות מייצרת קובץ שנראה כמו עדכון הדרגתי רגיל, מעוצב-היטב, ברמת-הבייט, ממש עד שקורא תואם פותח אותו

שישה מזריקי סמנים, שער הצפנה אחד ב-v2.14.2

PDFiumPas שולחת שישה מזריקי-סמנים ברמת-בייט, אחד לכל קבוצת-משנה של ISO PDF שהיא יכולה לתייג: PDF/A (‏ISO 19005), ‏PDF/X (‏ISO 15930), ‏PDF/UA (‏ISO 14289-1), ‏PDF/E-1 (‏ISO 24517-1), ‏PDF/R-1 (‏ISO 23504-1), ו-PDF/VT-1 (‏ISO 16612-2). כל אחד לוקח את הבייטים ש-FPDF_SaveAsCopy של PDFium עצמה כבר כתבה ומשכב מעליהם עדכון הדרגתי שני, קטן יותר: זרם מטא-נתוני XMP חדש, עריכת מילון קטלוג שמצביע עליו, ועבור קבוצות-המשנה מכוונות-ההדפסה, OutputIntent ופרופיל ICC. נכון ל-v2.14.2, כל אחת מ-InjectPdfAMarkers, ‏InjectPdfXMarkers, ‏InjectPdfUaMarkers, ‏InjectPdfEMarkers, ‏InjectPdfRMarkers, ו-InjectPdfVTMarkers קוראת את טריילר המקור קודם, ואם הוא מדווח על רשומת /Encrypt קיימת, מעתיקה את המקור אל היעד ללא שינוי וחוזרת מיד. אין XMP, אין OutputIntent, אין עריכת קטלוג — הקוד הקורא מקבל בחזרה את הקובץ המקורי, בייט לבייט

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf is byte-identical to the source: still encrypted,
    // no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
    // nothing was corrupted either
  finally
    Dst.Free;
    Src.Free;
  end;
end;

מותר-להיות-מוצפן אינו זהה לבטוח-להזרקה-לתוכו

גם PDF/E-1 וגם PDF/R-1 מאפשרים במפורש למסמך המארח שלהם להיות מוצפן ברמת המפרט, מה שנקרא כפטור עד שאתה מסתכל על מה שבפועל חייב לקרות על הדיסק. ‏ISO 24517-1 סעיף 6.3 מתיר הצפנה עבור PDF/E-1, ו-ISO 23504-1 סעיף 6.2.3 מתיר אותה עבור PDF/R-1 בתנאי שהכותרת מצהירה %PDF-2.0. אף אחד מהסעיפים לא אומר כלום על אם post-processor ברמת-בייט יכול בבטחה להוסיף אובייקט טקסט-גלוי למכולה המוצפנת ההיא, והוא לא יכול, מאותן סיבות סעיף 7.5.6 שחלות על כל קבוצת-משנה אחרת. המאמתים המובנים של PDFiumPas עצמה עבור שני הפרופילים האלה, ‏ValidatePdfECompliance ו-ValidatePdfRCompliance, רושמים את נוכחות /Encrypt במכוון בלי לסמן אותה כפגם, מה שנכון עבור מאמת קריאה-בלבד שאף פעם לא כותב בייט. זו גם תבנית קלה לדלג עליה במבט חטוף ולהניח שהמזריק-האח לא זקוק לשמירה נפרדת, כאשר המזריק הוא הפונקציה האחת בזוג שבאמת חייבת לסרב

האם SaveAsPdfX מפענחת (decrypt) את המסמך שלך בשקט?

כן, בכל פעם שאתה עובר דרך פונקציות הנוחות הציבוריות במקום לקרוא ישירות למזריק. כל אחת מ-TPdf.SaveAsPdfA, ‏SaveAsPdfX, ‏SaveAsPdfUa, ‏SaveAsPdfE, ‏SaveAsPdfR, ו-SaveAsPdfVT מעבדת את המסמך הנוכחי לתוך זרם זמני עם SaveAs(Tmp, saRemoveSecurity) לפני שהיא מוסרת את הבייטים ההם למזריק המתאים שלה. ‏saRemoveSecurity ממופה לדגל FPDF_REMOVE_SECURITY של PDFium עצמה, כך שהעותק הזמני שהמזריק מקבל אף פעם לא היה מוצפן מלכתחילה, ושמירת ה-/Encrypt של המזריק אף פעם לא זוכה לסיבה להפעיל. הפלט נושא את סמני ה-PDF/A, ‏PDF/X, ‏PDF/UA, ‏PDF/E-1, ‏PDF/R-1, או PDF/VT-1 שלך, אבל הוא כבר לא מוגן על ידי איזו סיסמה שפתחה את המקור

הפשרה הזו בלתי-נראית עד שמישהו במורד-הזרם פותח את העותק הארכיוני ה"מוגן" בלי סיסמה ושם לב שזה סתם עובד. התיקון אינו קריאת-פונקציה שונה; ל-PDFiumPas אין saAddSecurity מקביל לצמד עם saRemoveSecurity, משום שמנוע ה-PDFium הבסיסי אף פעם לא נבנה לכתוב הצפנה חדשה, רק להסיר אותה. אם שתי התכונות חשובות עבור קובץ אחד, הצפנה חייבת להיות שלב נפרד שאתה מחזיק, מיושם אחרי סמני התאימות, לא מקופל לתוך אותה קריאת SaveAsPdfA

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // needed to open the source at all
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
    // ran first inside SaveAsPdfA: the output opens without a password
  finally
    Pdf.Free;
  end;
end;

מה קורה כשאתה חותם על PDF מוצפן עם PAdES?

PDFiumPas מסרבת לחלוטין, במקום להפיל את הבקשה בשקט באופן שמזריק-סמנים עושה. גם TPdf.SignPades וגם SignPadesToStream מנתבים דרך SignPadesBytes פנימית, והדבר הראשון שהיא עושה אחרי פענוח טריילר המקור הוא בדיקה עבור /Encrypt. אם הרשומה נוכחת, היא מעלה EPadesCrypto עם ההודעה "SignPadesBytes: the source document is encrypted; remove encryption before signing" במקום להמשיך הלאה. ‏InjectPadesDssMarkers, הפונקציה ששובצת אישורים, תגובות OCSP, ו-CRLs עבור אימות ארוך-טווח, מיישמת את אותה בדיקה בדיוק מאותה סיבה, עם ההודעה שלה עצמה: "InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material"

ההיגיון כאן קפדני יותר מהמעבר-בלי-נגיעה של מזריקי הסמנים, ובמכוון. מעבר שקט בטוח עבור חותמת PDF/A משום שדילוג עליה משאיר אותך עם אותו PDF תקף שהתחלת בו, פשוט בלתי-מתויג. חתימה לא יכולה להיכשל בשקט הזה: חתימה שבשקט אף פעם לא נוספה נראית, לכל קוד קורא שרק בודק תוצאה בוליאנית, בדיוק כמו חתימה שנוספה בהצלחה. ‏EPadesCrypto יורדת ממחלקת ה-Exception הרגילה, כך שלכידתה היא טיפול חריגות רגיל, לא מוסכמת זרימת-בקרה מיוחדת שאתה צריך ללמוד

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

סידור סדר חותמות התאימות, חתימות, והצפנה

התיקון המעשי הוא סידור, לא ספרייה שונה. יישם סמני PDF/A, ‏PDF/X, ‏PDF/UA, ‏PDF/E-1, ‏PDF/R-1, או PDF/VT-1 קודם, הוסף כל חתימת PAdES אחר-כך, ורק אז הרץ איזה שלב בצינור שלך שבפועל מחזיק הצפנה, בין אם זה כותב PDF ייעודי, מכשיר חתימה, או מימוש AES משלך. שכבת העדכון-ההדרגתי של PDFiumPas מתאימה באופן טבעי לאמצע הרצף הזה, מוסיפה אובייקטים קטנים, ממוקדים, לקובץ שאחרת מוגמר, והצפנה שייכת בסוף בדיוק משום שזו הפעולה היחידה בשרשרת ש-PDFiumPas עצמה לא יכולה לבצע או להפוך

שום דבר מזה לא משנה איך PDFiumPas קוראת את נתוני הטריילר וההפניה-הצולבת שכל עדכון הדרגתי תלוי בהם, שהוא מקור דקויות משלו ברגע שזרמי xref נכנסים לתמונה; אימות זרמי אובייקט וxref של PDF מכסה איך אותו נתיב קריאת-טריילר מטפל במבנים דחוסים של PDF 1.5 ומעלה. וברגע שמסמך מוכן למשהו חזק יותר מחותמת תאימות, חתימה על PDF עם חתימת PAdES B-B ב-Delphi הוא המקום ש-SignPades נכנסת לתמונה בדיוק מהנקודה שהמאמר הזה עוצר בה

מזריקי-הסמנים ופונקציות SignPades המתוארים כאן נשלחים כחלק מרכיב PDFium עבור Delphi ו-C++Builder, לצד העיבוד והביקורת-הקריאה-בלבד ש-PDFium מספקת באופן ילידי