מאמר טכני

DocMDP ו-FieldMDP: ביקורת רוויזיות PDF בדלפי

PDF חתום שהשתנה אחרי החתימה אינו בהכרח פגום. תקן ISO 32000-1 מתיר עדכונים incremental מעל חתימה, ורק חלקם שוברים את המדיניות שהחותם קבע. HotPDF Component עבור דלפי ו-C++Builder עונה על השאלה הזו עם AnalyzeLoadedSignatureRevisions, שמסווגת כל רוויזיה שלאחר-החתימה ומדרגת אותה מול DocMDP ו-FieldMDP. התרחיש מוכר לכל מי שמשלח תוכנת חוזים: הלקוח שלך חותם על הסכם רכישה, שולח אותו החוצה, ומקבל אותו חזרה עם עמוד נספח מצורף. הקורא מציג פס צהוב שאומר שהחתימה שלמה אבל המסמך השתנה מאז שנחתם, ואף אחד בחדר לא יכול לומר האם זה תהליך עבודה רגיל של חתימה נגדית או מישהו שעורך בשקט חוזה חתום

מה נחשב לשינוי חוקי אחרי חתימה?

שינוי הוא חוקי כשהקטגוריה הסמנטית שלו נופלת בתוך ההרשאה שהחתימה המאשרת הצהירה. תקן ISO 32000-1 §12.8.2.2 מגדיר את טרנספורם DocMDP עם ערך /P של 1, 2 או 3: 1 מתיר אפס שינויים, 2 מתיר מילוי טפסים וחתימה, 3 מתיר מילוי טפסים, חתימה והערות. HotPDF חושפת אותם כערכי THPDFDocMDPPermission dmpNoChanges, dmpFormFillAndSign ו-dmpFormFillSignAndAnnotate, עם dmpNone שמורה לתוצאות בדיקה שלא נושאות שום טרנספורם DocMDP

הקטגוריות מסודרות, והסדר הזה הוא המנוע של הבדיקה כולה. THPDFRevisionModificationLevel מריצה rmlNone, rmlLongTermValidation, rmlFormFillAndSign, rmlAnnotations, rmlOther, מסודרות בכוונה כך שסודר גדול יותר לעולם לא פחות מגביל. מסמך שלם מצטמצם לרמה המקסימלית שנצפתה על פני כל רוויזיה אחרי החתימה, וההשוואה ל-DocMDP הופכת לבדיקת מספר שלם יחידה. ניואנס אחד חשוב מוקדם: ב-dmpNoChanges הניתוח עדיין מקבל rmlLongTermValidation. הוספת חומר אימות DSS ו-VRI או חותמת זמן מסמך לקובץ מאושר היא תחזוקה של החתימה, לא שינוי של המסמך, והתייחסות אליה כהפרה הייתה שוברת כל תהליך עבודה של ארכיון ארוך-טווח שקיים

איך HotPDF בונה מחדש את שרשרת הרוויזיות?

באופן מבני, לא היוריסטי. לפי תקן ISO 32000-1 §7.5.6 עדכון incremental מוסיף קטע הפניה צולבת חדש ש-/Prev שלו מצביע על הקודם, כך ש-HotPDF קוראת את startxref מהזנב, מנתחת את הקטע שם, עוקבת אחרי /Prev לאחור וחוזרת, ומחזירה את הקטעים מהישן ביותר קודם. שני מגבלות בטיחות יושבות בלולאה הזו ושתיהן שוות ידיעה כשמאתרים תקלה בקובץ שנכשל: /Prev שמצביע על היסט שכבר נבקר מסיים את ההליכה עם אבחון מעגל מפורש במקום להסתחרר, ושרשרת ארוכה מאלף רוויזיות נדחית לחלוטין. שניהם עולים על פני השטח ב-Analysis.Issue עם הפונקציה שמחזירה False, ואף אחד מהם לא צריך להיטפל, כי /Prev מעגלי הוא קובץ פגום או עוין ולא סתם חריג

ארבע צורות היסטוריות מופיעות במסמכים אמיתיים וכל הארבע מטופלות: טבלאות xref מסורתיות שמנותחות שורה-שורה, זרמי הפניה צולבת שמפוענחים ומפוענחים דרך שדות /W ו-/Index שלהם, קבצי הפניה-היברידית שה-trailer המסורתי שלהם נושא מפתח /XRefStm שמנותח וממוזג לאותה רוויזיה (מקרה מפיק Office, מכוסה בהמאמר על זרמי הפניה צולבת היברידיים), ואובייקטים שחיים בתוך קונטיינר ObjStm, שחשובים כי עדכון מודרני בדרך כלל שם את המילון שהשתנה בזרם דחוס במקום לכתוב אותו ישירות, כמתואר בהמאמר על זרמי אובייקטים ועדכונים incremental. החתימה מעגנת את הפיצול: /ByteRange[2] + /ByteRange[3] הופך ל-SignedRevisionLength, וכל קטע באותו היסט או מעבר לו הוא שלאחר-חתימה. האם טווח הבייטים עדיין מתגבב נכון היא שאלה נפרדת, שנענית על ידי VerifyLoadedSignature ומכוסה בהמאמר על אימות חתימות דיגיטליות ב-PDF

איך כל אובייקט שהשתנה מסווג

הסיווג רץ לפי אובייקט, ואז מתפשט לאורך הפניות. עבור כל מספר אובייקט שקטע שלאחר-חתימה נוגע בו, HotPDF קוראת את הגוף החדש ואת הגוף כפי שהיה בתמונת המצב החתומה; גוף זהה הוא rmlNone, כי מפיקים אכן כותבים מחדש אובייקטים בלי לשנות אותם. המזהים צרים בכוונה. אובייקט /Type /DocTimeStamp, או אחד ש-/SubFilter שלו הוא ETSI.RFC3161, הוא rmlLongTermValidation, כמו כל דבר שנגיש מעץ /DSS של הקטלוג; מילון /Type /Sig הוא rmlFormFillAndSign. עבור קונטיינרים הבדיקה היא אילו מפתחות זזו, לא מה האובייקט: הקטלוג רשאי רק לרכוש או לשנות /DSS, /Extensions או /AcroForm; מילון ה-AcroForm רק /Fields, /SigFlags, /NeedAppearances, /DR, /DA או /Q; עמוד רק /Annots; שדה או ווידג'ט רק /V, /AP, /AS או /M. כל דבר מחוץ לקבוצות אלה נופל ל-rmlOther, וזה בדיוק איך עמוד הנספח שנוסף נתפס: הוספת עמוד מסדרת מחדש את עץ העמודים בדרכים ששום רשימה-לבנה לא מכסה, ושום מילוי טפסים חוקי לא דומה לזה

ואז הרמות מתפשטות, כאשר כל קונטיינר יורש את הרמה המקסימלית של הילדים שהשתנו שהוא מצביע עליהם, חוזר ונשנה עד שההקצאה מתייצבת. זה מה שגורם לזרמי מראה לעבוד. שדה טקסט שמולא כותב מחדש את /V ומצביע על זרם /AP טרי, וזרם זה בפני עצמו הוא בלוב אנונימי של אופרטורי תוכן ללא סוג לזהות; מכיוון שהשדה שמחזיק בו הוא rmlFormFillAndSign, הזרם יורש את אותה רמה במקום ליפול ל-rmlOther. אותה התפשטות נושאת הקשר DSS לזרמי תעודה וביטול שאחרת היו בלתי-ניתנים לסיווג

למה אובייקט לא קריא נחשב להפרה?

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

שחרור הוא החד ביותר מהשלושה. רוויזיה שלאחר-חתימה שמסמנת אובייקט שהוגדר קודם כחופשי מחקה תוכן ממסמך חתום, ואף רמת הרשאה תחת §12.8.2.2 לא מתירה זאת; מספרי האובייקטים נוחתים ב-FreedObjectNumbers והרוויזיה מועלית ל-rmlOther. אובייקטים לא-קריאים הולכים לפי אותה לוגיקה מסיבה שונה. מאמת שלא יכול לפענח אובייקט אין לו בסיס לקרוא לו לא-מזיק, והתגובה הכנה לזה אינה שתיקה. דיווח על מבנה יוצא-דופן אך תמים כהפרה עולה ביקורת אנושית; הטעות ההפוכה משלחת חוזה חתום עם עריכה שלא הובחנה בתוכו

קריאת הפסק בדלפי

הקריאה קצרה. טען את המסמך, בחר אינדקס חתימה, קרא את הרשומה; העומס-יתר חסר-הפרמטרים פותח מחדש את הקובץ שהמסמך נטען ממנו, ועומס-היתר TStream לוקח בייטים שסופקו על ידי הקורא ומשחזר את מיקום הזרם לפני שהוא חוזר. PolicyCompliant הוא הבוליאני היחיד שרוב הקוראים רוצים, משלב שלוש החלטות עצמאיות: תקפות מבנית של מילוני ההרשאה, DocMDPCompliant, ו-FieldMDPCompliant. שמור על הרכיבים גלויים בממשק שלך במקום לקפל אותם, ושים לב שמסמך ללא טרנספורם DocMDP משאיר את DocMDPCompliant ב-True, כיוון שחתימת אישור רגילה לא מצהירה על שום מדיניות להפר, ו-ModificationLevel המצטבר הוא אז תיאורי ולא פסק

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

לצורך מיון בדרך כלל תרצה את הפירוט לפי-רוויזיה במקום את הסיכום, כי הוא אומר מתי בהיסטוריית המסמך דברים השתבשו. כל רשומה ב-Analysis.Revisions נושאת את האינדקס שלה בשרשרת, את היסט ההפניה הצולבת שהיא נכתבה בו, את רמת השינוי שלה עצמה, ואת מספרי האובייקטים המעורבים

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP נשפט בנפרד, וזה מכוון

מסמך יכול לספק את DocMDP ועדיין להיות בלתי-לגיטימי, וזו הסיבה ש-FieldMDPCompliant הוא בוליאני נפרד ולא מקופל להשוואת הרמה. תקן ISO 32000-1 §12.8.2.4 מגדיר את טרנספורם FieldMDP, ו-§12.7.5.5 את רשומת /SigFieldLock הקשורה, כדי להקפיא שדות טופס בעלי-שם ברגע החתימה גם כשהמסמך כולו עדיין מתיר מילוי טפסים. מילוי שדה הוא פעולת רמה 2; מילוי שדה שהחותם נעל הוא הפרה ללא קשר לרמה. HotPDF קוראת את ההיקף ל-THPDFFieldLockAction כ-flaAll, flaInclude או flaExclude, עם flaNone לתוצאות שלא נושאות שום מדיניות נעילה, ואת השמות ל-Permissions.FieldNames: flaAll נועל הכול, flaInclude נועל את השמות המפורטים, flaExclude נועל הכול חוץ מהם. פרט אחד חשוב בקריאת התוצאות, בכך שרק שדות שכבר נוכחים בתמונת המצב החתומה מדווחים ב-ChangedFieldNames, כי שדה שנוצר לגמרי אחרי החתימה אין לו מצב חתום לסתור והוא נתפס על ידי נתיב DocMDP במקום

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

מה הניתוח הזה לא יגיד לך

הוא לא מאמת חתימה. AnalyzeLoadedSignatureRevisions מנמק על מבנה והרשאות; האם טווח הבייטים החתום עדיין מתגבב לערך ב-blob ה-CMS, והאם שרשרת התעודה של החותם מגיעה למשהו שאתה סומך עליו, נענות על ידי VerifyLoadedSignature ו-VerifyLoadedSignatureWithTrust. קובץ יכול להיות תואם-מדיניות באופן מושלם וחסר-ערך קריפטוגרפית, כך ששתי הבדיקות שייכות זו לצד זו בכל שער קבלה אמיתי. הוא גם לא קורא כוונה בתוך זרמי תוכן: עמוד שזרם התוכן שלו הוחלף לגמרי נתפס כשינוי מחוץ לרשימה-הלבנה, אבל הניתוח לא יגיד לך שההחלפה החליפה סכום תשלום. פסק rmlOther אומר שאדם צריך להסתכל, לא שהתרחשה הונאה, ופסק תואם אומר שהשינוי מתאים לקטגוריה מותרת, לא שהשינוי היה רצוי. כשכל מה שאתה צריך הוא מה שהחותם הצהיר, בלי הליכת הרוויזיה, GetLoadedSignaturePermissions מחזירה את מילוני המדיניות בפני עצמם

כל מה שמתואר כאן רץ באופן ילידי בדלפי וב-C++Builder ללא שירות חתימה חיצוני בלולאה, וזה מה שהופך את זה למעשי להריץ על כל מסמך נכנס במקום רק על אלה שמישהו כבר חשד בהם. ה-API המלא של חתימה ורוויזיה, כולל שיטות ההרשאה והאימות שהוא מזווג איתן, הוא חלק מ-HotPDF Component עבור דלפי ו-C++Builder