מאמר טכני

תיקוף שרשרת רוויזיות של PDF MAC ב־Delphi (ISO 32004)

HotPDF מתקף PDF MAC לפי ISO/TS 32004 ברמת הרוויזיה ולא ברמת הקובץ. THotPDF.ValidatePDFMACChain עובר על כל עדכון מצטבר מנקודת העיגון של השרשרת ואילך ומאמת כל MAC מול זרם קידומת לקריאה בלבד שמסתיים ב־startxref וב־%%EOF של אותה רוויזיה עצמה. MAC אחד תקף על הרוויזיה החדשה ביותר לא מוכר דבר על הרוויזיות מתחתיה

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

למה MAC תקף ברמה העליונה לא מוכר שהקובץ תקין?

כי PDF MAC מכסה קידומת, לא מסמך. עדכון מצטבר הוא חלק ממלא בפורמט: כל שמירה מצמידה גוף חדש, מקטע הפניות צולבות חדש וטריילר חדש, בזמן שהבתים הישנים נשארים בדיוק היכן שהיו. ISO/TS 32004 רוכב על המודל הזה, ולכן כל רוויזיה נושאת מילון /AuthCode משלה שמאמת את הקובץ כפי שעמד באותו רגע, ותיקוף של החדשה בלבד משאיר כל רוויזיה קודמת בלי בדיקה. לכן HotPDF חושף את שתי השאלות כשתי קריאות, וההבדל ביניהן הוא כל הנקודה של המאמר הזה. ValidatePDFMAC עונה על "האם הרוויזיה הנוכחית אותנטית" וממלא רשומת THPDFPDFMACValidationInfo; ValidatePDFMACChain עונה על "האם כל רוויזיה מוגנת־MAC בקובץ הזה אותנטית" וממלא את THPDFPDFMACChainValidationInfo עם מערך פר־רוויזיה וסיבת כשל קריאה למכונה. על הקובץ ששובש ואז קיבל MAC חדש למעלה, הקריאה הראשונה מחזירה True והשנייה מחזירה False מול אינדקס רוויזיה 1

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // הכשל הוא אחד מ־pmcfRevisionBoundary, pmcfNoPDFMAC,
      // pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
      // pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

כל MAC מאומת על זרם הקידומת שלו, לעולם לא על אורך הקובץ הסופי

הבאג היקר ביותר בתחום הזה הוא שימוש בגודל הקובץ הסופי כגבול עליון בעת ה־re-hashing של רוויזיה ישנה, מה שמקפל בתים מצמידים לכל digest פרט לחדש ביותר ומדווח על שיבוש בקובץ תקין. HotPDF בונה במקום זאת, לכל רוויזיה, זרם קריאה בלבד חסום שמסתיים בערך ה־startxref של אותה רוויזיה ואחריו ה־%%EOF שלה, ומהשה רק אותו. איתור הגבול מסובך יותר ממה שנדמה: הליטרלי %%EOF יכול להופיע בתוך content stream או מחרוזת, ולכן מועמד מתקבל רק כשה־startxref שמיד לפניו מנותח למספר שווה להיסט ההפניות הצולבות של המקטע המתוקף, עם לא יותר מרווח לבן ביניהם. הרוויזיה אז סופגת בדיוק רצף סוף־שורה אחד אחרי הסימון — CR בודד, LF בודד, או זוג CRLF אחד — ולא יותר. הכלל האחרון הזה נושך בפועל, כי כותב שפולט שורה ריקה נוספת בין שתי רוויזיות הפיק בתים ששייכים לרוויזיה הבאה, ובליעת כל הרווח המצמיד לרוויזיה הקודמת משנה בשקט את שני ה־digests. ספירת המקטעים מקפידה על אותו משמעת: HotPDF עובר על מקטעי ההפניות הצולבות מהישן לחדש בדיוק פעם אחת, משחזר רשומות free, direct ו־object-stream כך שמקטעים מאוחרים דורסים מצב קודם — ההפך מסמנטיקת ה־first-seen-wins שמנתח xref פעיל מיישם

HotPDF מאמת כל PDF MAC של ISO 32004 מול זרם קידומת שמסתיים ב־startxref ובסימון סוף־הקובץ של אותה רוויזיה עצמה, ולכן בית שהתהפך בתוך רוויזיה 1 מפיל את השרשרת גם כשה־MAC החדש ביותר עדיין מתוקף נקי
ה־MAC של כל רוויזיה מהושה מחדש על הקידומת החסומה שלו, ולכן עריכת רוויזיה 1 והצמדת רוויזיה טרייה עם MAC עדיין משביעה רצון את ValidatePDFMAC בזמן ש־ValidatePDFMACChain נוחת על רוויזיה 1

איפה השרשרת עוגנת, ומה שובר אותה?

הרוויזיה הראשונה שנושאת /AuthCode תקף היא העוגן, ו־FirstMACRevisionIndex מדווח איפה ההגנה מתחילה; כל מה שלפניה בלתי־מוגן מבחירת מבנה, וזה נורמלי. כל מה שאחריה חייב להיות מוגן־MAC, ולכן הצמדת עדכון מצטבר פשוט אחד לקובץ מוגן־MAC נכשלת עם pmcfRequiredRevisionMissing ואינדקס הרוויזיה הפוגעת — סובלנות לפער הייתה מאפשרת לתוקף להשיל הגנה פשוט על ידי שמירה נוספת אחת. שלושה אינווריאנטים נוספים מחזיקים לאורך השרשרת, ולכל אחד קוד כשל משלו

  • pmcfKDFSaltChanged — ה־/KDFSalt חייב להישאר יציב מהעוגן ואילך, כי מלח מתחלף היה מאפשר למזייף לגזור מפתחות מחדש תחת פרמטרים שהוא עצמו בחר
  • pmcfDigestDowngrade — עוצמת ה־digest נמדדת מול ה־MAC המאומת האחרון ולא מול הרוויזיה שמיד לפני, ולכן שרשרת שמתחילה תחת פרופיל Modern ב־SHA-384 אינה יכולה להמשיך בשקט עם SHA-256
  • pmcfPermissionDowngrade — רוויזיה אסור שתבטל דרישת PDF MAC שרוויזיה קודמת אימתה

המסקנה שכדאי להפנים היא שה־MACs ההיסטוריים מאומתים באופן בלתי־תלוי גם אחרי שהם כבר אינם הטריילר הפעיל. זו הסיבה שהתקפה מהפתיח — עריכת רוויזיה ישנה ואז הצמדת MAC טרייה — לא שורדת: ה־MAC החדש ביותר עובד בדיקה משלו, ValidatePDFMAC מרוצה, והשרשרת עדיין נוחתת על רוויזיה 1 עם pmcfRevisionInvalid

סדר החתימה: מפתחות הטריילר קודם, signatureDigest אחרון

כשה־MAC מחובר לחתימת CMS ולא עומד לבדו, סדר הכתיבה מפסיק להיות שאלה של סגנון. HotPDF דורש ש־/AuthCode, /KDFSalt, הרחבת המפתח של ISO 32004 ו־/SigObjRef ייכתבו לאותה רוויזיה לפני חישוב ה־/ByteRange של החתימה; הצמדה של אחד מהם אחר כך משגרת את הבתים האלה אל מחוץ לטווח שהחתימה מכסה, ומפיקה קובץ שהחתימה שלו מתוקפת בזמן שקשירת ה־MAC אינה חתומה. שני ה־digests רצים אז לכיוון השני, מה שנראה מעגלי במבט ראשון ואינו. ה־signatureDigest של PDF MAC קושר את אוקטטות התוכן הגולמיות של ה־OCTET STRING של SignerInfo.signature ב־CMS — לא את כל ה־DER של ה־CMS ולא את התכונות החתומות — ולכן הוא נבנה אחרי שערך החתימה הגולמי קיים ומוזרק כתכונה לא־חתומה id-attr-pdfMacData. מכיוון שה־/Contents מוצא מה־ByteRange של החתימה ותכונות לא־חתומות אף פעם לא מזינות את חישוב החתימה, הרצף ייצור־חתימה, בניית־MAC, עטיפת־CMS נסגר נקי בלי מעגל קריפטוגרפי. שתי מסקנות מתלוות: סנטינל ה־/ByteRange ומציין המקום של ה־/Contents חייבים להישאר plaintext ומחוץ ל־object streams גם בקובץ מוצפן, אחרת המתקן ברוחב קבוע לא ימצא אותם; וכשה־digest של ה־MAC הוא גם SHA-256, digest החתימה מנוצל כמות שהוא, אחרת שני הקשרי ה־digest מתעדכנים במעבר אחד על זרם הפלט

סדר הכתיבה של HotPDF ל־PDF MAC המחובר לחתימת CMS: מפתחות ה־MAC נכנסים לרוויזיה לפני מדידת ה־ByteRange, וה־digest של החתימה נבנה אחר כך מאוקטטות החתימה הגולמיות של SignerInfo
כתיבת AuthCode, KDFSalt, SigObjRef והרחבת המפתח לפני מדידת ה־ByteRange היא זו שמחזיקה את קשירת ה־MAC בתוך הטווח שהחתימה מכסה
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // digest מסמך SHA-384
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature, ושני ה־digests
        // מדווחים בנפרד
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

התיקוף שב אותו דרך מהצד השני: קוראים את ה־/AuthCode הישיר מהטריילר הקלאסי הפעיל של ההפניות הצולבות, עוקבים אחרי ה־/SigObjRef העקיף מודע־הדור, מאשרים שהוא קושר את ה־/V של שדה החתימה היחיד, ומדווחים על כשל digest מסמך בנפרד מכשל digest חתימה. אלה אבחנות שונות, וצמצומן לבוליאני אחד משליך את המידע היחיד שאומר אם תוכן העמוד או ערך החתימה נגעו. אם אתם כבר עובדים עם CMS, זה יושב לצד מאמר החתימה של PAdES והמדריך לתיקוף חתימות במסמכים טעונים

אל תסמכו על /P: פענחו קודם את ה־/Perms בן 16 הבתים

ISO/TS 32004 מאותת "המסמך הזה דורש PDF MAC" דרך ביט הרשאה 13, והדרך המובנת מאליה לקרוא אותו היא הדרך הלא נכונה, כי המספר /P במילון ההצפנה הוא plaintext ולא מאומת — כל אחד יכול להפוך את הביט הזה בעורך טקסט ולהוריד את הדרישה בדרגה. ISO 32000-2 §7.6 מספק את התשובה ברשומת ה־/Perms, ו־HotPDF משתמש בה: מפענחים את מחרוזת ה־/Perms בת 16 הבתים עם מפתח הצפנת הקובץ תחת AES-256 CBC, IV אפס, בלי padding, ואז בודקים כל שדה של הטקסט הגלוי לפני שמאמינים למשהו. בתים 1 עד 4 מחזיקים את ערך ההרשאה בסדר little-endian וחייבים להשתוות בדיוק למספר ה־/P; בתים 5 עד 8 הם 0xFF; בית 9 הוא דגל הצפנת המטא־נתונים T או F; בתים 10 עד 12 הם הסימון הליטרלי adb. רק כשכל זה מתקיים PermissionsAuthenticated הופך True וביט 13 נקרא — ושימו לב לפולריות שלו, כי דרישת ה־MAC נטענת כשהביט 0x1000 כבוי. אי־התאמה בין /P להרשאות המפוענחות אינה אזהרה לרשום ולהמשיך; זו קבוצת הרשאות מזויפת, והתגובה הנכונה היא כשל סגור

HotPDF מאמת הרשאות PDF על ידי פענוח מחרוזת ה־Perms בת שישה־עשר הבתים עם מפתח הצפנת הקובץ ובדיקת ערך ההרשאה ב־little-endian, בתי ה־FF, דגל המטא־נתונים וסימון ה־adb לפני קריאת ביט 13
המספר /P בטקסט גלוי אינו מאומת, ולכן דרישת ה־PDF MAC נקראת רק אחרי בדיקת כל שדה של ה־/Perms המפוענח

גמישות האלגוריתם נעצרת ב־digest

ISO/TS 32004 מאפשר לבחור את digest המסמך, ורק את digest המסמך. HotPDF שומר על HMAC-SHA-256 לאימות, HKDF-SHA-256 לפי RFC 5869 לגזירת מפתחות ו־AES-256 key wrap לפי RFC 3394 קבועים מתחת ל־THPDFPDFMACDigestAlgorithm משתנה שנע מ־pmdaSHA256 ועד pmdaSHA3_512, כי הטעות הטבעית היא לראות ב"פרופיל SHA3-512" רישיון להחליף גם את ה־HMAC, מה שמפיק קובץ שאינו PDF MAC בשום מובן אינטרופרבילי. פרט מימוש אחד שווה העתקה אם אתם כותבים מאמת משלכם: קראו את ה־OID של ה־digest מתוך ה־AuthenticatedData של ה־CMS לפני ההשה של טווח הבתים, כי קידוד קשיח של SHA-256 והתאמה אחר כך הופכים את הגמישות לתווית ומאפשרים לקובץ עוין לגרום לכם לזרום את כל המסמך עד שתגלו שהאלגוריתם מעולם לא נתמך. CMSAlgorithmProtection, אלגוריתם ה־digest של ה־AuthenticatedData, ה־messageDigest של ה־integrity-info וה־digest של טווח הבתים חייבים כולם לציין אלגוריתם אחד, וכל חוסר הסכמה נכשל סגור

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256, מקבל את כל השישה
  Options := THPDFPDFMACOptions.Modern;         // SHA-384, דוחה 256-bit
  Options := THPDFPDFMACOptions.HighAssurance;  // SHA3-512 בלבד, AES-GCM

  // פרופיל מותאם אישית הוא חוקי, אבל האלגוריתם שהוא חותם איתו
  // חייב גם להופיע ברשימת ההיתר של התיקוף, אחרת התצורה
  // נדחית לפני שנכתב בית אחד
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

מה PDF MAC מוכיח ומה לא

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

נקודות הכניסה של PDF MAC שתוארו כאן — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC ו־ValidatePDFMACChain — מגיעות עם HotPDF Delphi Component הרגיל עבור Delphi ו־C++Builder, שם עמוד המוצר נושא את ההפניה המלאה לרשומת האפשרויות, לערכי מנוי המצב ולמערך התיקוף הפר־רוויזיה