מאמר טכני

ניתוח שינויי PDF לאחר חתימה עם PDFium ב-Delphi

כדי לגלות מה השתנה ב-PDF אחרי שנחתם, PDFium Component עבור Delphi ו-Lazarus מספק את TPdf.AnalyzeSignatureRevisions, מנתח שינויי מהדורות לאחר חתימה שבונה כל מהדורה מצטברת מחדש מהבייטים המקוריים של הקובץ, מדרג כל שינוי אובייקט מאוחר מול כללי ה-DocMDP וה-FieldMDP של אותה חתימה, ומדווח על הגדרות אובייקט צל בתור סיכון נפרד. התרחיש שהוא פונה אליו מוכר לכל מי שמטפל בחוזים: טופס מאושר יוצא, חוזר עם עוד שתי שמירות מצטברות, וכל חתימה עדיין מאומתת. זה מצופה, כי חתימה מכסה רק את הבייטים של המהדורה שלה עצמה. השאלה האמיתית היא האם השמירות המאוחרות האלה היו מותרות, וסימן ביקורת ירוק על החתימה לא עונה עליה

למה ה-API של חתימות PDFium לא מציג מה השתנה אחרי החתימה?

ה-API של חתימות PDFium לא יכול להציג שינויים לאחר חתימה כי הוא קורא רק את מילון החתימה: /Contents, /ByteRange, /SubFilter וערך ההרשאות DocMDP. ל-PDFium אין גרף מהדורות מצטברות, הוא לא מפרס פרמטרי טרנספורמציה של FieldMDP, ואינו מציע diff ברמת אובייקטים בין מהדורות, כך שהמנתח ב-FPdfPades.pas עובד במקום זאת ישירות על בייטים גולמיים. לזה יש תוצאה מעשית שכדאי לתכנן לפיה. TPdf.AnalyzeSignatureRevisions קורא את הבייטים שנשמרו בזמן טעינת המסמך, לעולם לא עותק שהופק על ידי SaveAs, כי קובץ שנכתב מחדש איבד בדיוק את מבנה המהדורות שמנותח. אם המסמך הגיע ממקור מתקדם שטרם סיים להוריד, הדוח מחזיר SourceStatus = pvssIncomplete ו-Status = prasIndeterminate במקום לנתח קובץ קטוע

בניית גבולות מהדורות מחדש מ-startxref, זרמי xref ו-/Prev

המנתח בונה גבולות מהדורות מחדש על ידי מעקב אחרי כל startxref לאחור דרך טבלאות xref קלאסיות, זרמי הפניות צולבות, כניסות /XRefStm של הפניה היברידית ושרשרת ה-/Prev, כפי שהוגדר עבור עדכונים מצטברים ב-ISO 32000-1 §7.5.6 ו-§7.5.8. אורך הכיסוי של כל חתימה הוא סוף מקטע ה-ByteRange השני שלה, והמנתח ממפה את האורך הזה למהדורה שמקטע ה-xref שלה הוא נופל בתוכה. כשאף מהדורה לא תואמת, החתימה מקבלת prrCoveredRevisionNotFound ומצב Indeterminate. מצבו של כל אובייקט משוחזר אז עד למהדורה המכוסה, וכל כניסת xref מאוחרת מושווית מול המצב הזה. זה חשוב יותר משזה נשמע: כותבים מסוימים מנסחים מחדש את טבלת ה-xref המלאה בכל שמירה מצטברת, וכניסה שעדיין מצביעה על אותו אובייקט שלא השתנה מדולגת במקום להירשם כשינוי. בלי ההשוואה הזאת, מילוי טופס חוקי לחלוטין היה טובע במאות שינויים מדומים

איך AnalyzeSignatureRevisions בונה מהדורות מצטברות מחדש מבייטי PDF גולמיים ב-Delphi: ה-ByteRange של החתימה מסתיים בתוך המהדורה המכוסה, שרשרת ה-Prev של ה-xref הולכת לאחור דרך כל שמירה מאוחרת, מצב האובייקטים משוחזר עד למהדורה המכוסה, וכניסות מנוסחות מחדש שלא השתנו מדולגות במקום להירשם כשינויים
חתימה מכסה רק את הבייטים של המהדורה שלה עצמה, ולכן המנתח ממפה את מקטע ה-ByteRange השני למהדורה ומדרג כל כניסת xref מאוחרת מול מצב האובייקטים המשוחזר

הגדרות צל הן המקרה שראוי ליותר מכל תשומת לב. גוף אובייקט שמופיע בתוך טווח הבייטים של מהדורה מאוחרת אבל אינו מופנה על ידי ה-xref של אותה מהדורה בלתי נראה ל-viewer רגיל, ובכל זאת הוא בדיוק סוג ההיערכות שעליה מסתמכות התקפות צל: תוכן נסתר נשתל לפני החתימה או אחריה ומופעל מאוחר יותר על ידי היפוך הפניה. AnalyzePadesSignatureRevisionsBytes רושם אובייקט כזה בתור שינוי לא סמכותי עם IsAuthoritative = False, מדרג אותו prdSuspicious ללא תלות ברמת ההרשאות, ומוסיף prrUnreferencedObjectDefinition לסט הסיכונים. שני סיכונים קרובים מכסים טריקים מבניים אחרים: prrDuplicateObjectDefinition נדלק כשמקטע xref אחד מפרט את אותו אובייקט יותר מפעם אחת, ו-prrSignatureObjectRedefined נדלק כשמהדורה מאוחרת מגדירה מחדש אובייקט חתימה קיים

הגדרת אובייקט צל בתוך טווח הבייטים של מהדורת PDF מאוחרת: גוף האובייקט קיים אבל אף כניסת xref לא מפנה אליו, כך ש-viewers לעולם לא מציגים אותו, ו-AnalyzeSignatureRevisions ב-PDFium Component רושם אותו בתור לא סמכותי, מדרג אותו prdSuspicious ומעלה prrUnreferencedObjectDefinition לצד סיכוני השכפול והגדרת החתימה מחדש
תוכן נסתר נשתל לפני החתימה או אחריה ומופעל מאוחר יותר על ידי היפוך הפניה, ומכאן שגוף לא מופנה מדורג כחשוד ללא תלות ברמת ההרשאות של DocMDP
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

איך DocMDP ו-FieldMDP נאכפים עבור כל חתימה?

‏DocMDP ו-FieldMDP נאכפים בנפרד עבור כל חתימה, במהדורה המכוסה של אותה חתימה עצמה, כך שחתימת הסמכה וחתימת אישור מאוחרת באותו קובץ יכולות להגיע לפסקי דין שונים על אותה עריכה. כל אובייקט מאוחר מסווג קודם ל-TPadesRevisionChangeKind מהכניסות /Type, /Subtype ו-/FT שלו ומהתפקיד שהוא ממלא בגרפי העמוד, הטופס, האנוטציות וה-DSS. כל דבר שנושא /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia או /EmbeddedFile הופך prckActiveContent. ההחלטה אז עוקבת אחרי ISO 32000-1 §12.8.2.2: עם P=1 אסור הכל מלבד נתוני הפניות צולבות וחומר אימות; P=2 מתיר מילוי טפסים וחתימות נוספות אבל דוחה שינויי אנוטציות; P=3 מתיר גם אנוטציות. תוכן עמוד, מבנה מסמך, מטא-נתונים, תוכן פעיל ואובייקטים שנמחקו אסורים בכל רמת DocMDP, ומדורגים prdSuspicious כשהחתימה לא נושאת DocMDP בכלל, מכיוון שחתימת אישור לא אוסרת דבר באופן פורמלי אבל הקורא כבר לא רואה מה נחתם

‏FieldMDP, מתוך ISO 32000-1 §12.8.2.4, מצמצם עוד את החלטת שדות הטופס. pfmaAll נועל כל שדה, pfmaInclude נועל רק את השדות המפורטים, ו-pfmaExclude נועל הכל מלבד השדות המפורטים. כדי ליישם Include או Exclude, המנתח פותר כל שדה שהשתנה לשמו המלא המוסמך דרך שרשרת ה-/Parent ומשווה אותו מול רשימת הנעילה בהתאמה מדויקת, אז פרטו שמות שדות בעלי-קצה במקום לצפות ששם הורה יכסה את ילדיו. כששם לא ניתן לפתרון או שהטרנספורמציה משתמשת בפעולה שהפרסר אינו מזהה, השינוי הופך prdIndeterminate ומועלה prrFieldMdpUnresolved. ההחלטות לכל שינוי מגולגלות אז מהגרוע ביותר קודם, כש-Suspicious מדורג מעל Disallowed, Disallowed מעל Indeterminate, ו-Indeterminate מעל Allowed, כך שאובייקט צל אחד שוקל יותר מכל מספר של עדכוני שדות לגיטימיים

צינור הדירוג ש-AnalyzeSignatureRevisions מיישם על כל שינוי לאחר חתימה ב-Delphi: TPadesRevisionChangeKind מכניסות Type ו-Subtype, החלטת DocMDP במהדורה המכוסה מ-P=1 ועד P=3, בדיקת נעילת FieldMDP על שמות שדות מלאים מוסמכים, וגלגול מהגרוע ביותר קודם מ-prdSuspicious ועד prdAllowed
אובייקט צל אחד שוקל יותר מכל מספר של עדכוני שדות לגיטימיים כי Suspicious מדורג מעל Disallowed, Indeterminate ו-Allowed, בזמן שחלק מהסיכונים נרשמים לצד המצב בלי להוריד אותו

למה חלק מהשינויים חוזרים Indeterminate במקום בטוחים?

שינויים חוזרים Indeterminate בכל פעם שהמנתח לא יכול להוכיח ששינוי מותר, כי בבדיקת חתימה אסור לדווח על נעלם בתור מותר. מקרה נפוץ אחד מטופל בדיוק במקום זאת: אימות ארוך-טווח מוסיף /DSS וכותב את הקטלוג מחדש, מה שאחרת היה נחשב שינוי מבני תחת P=1. המנתח קורע /DSS ו-/Extensions ממילוני הקטלוג הישן והחדש ומשווה את השאר; כששום דבר אחר לא שונה, הכתיבה מחדש נחשבת עדכון חומר אימות ומותרת, כך שהרחבת B-LT ו-B-LTA לא שוברת חתימת הסמכה. פערים אחרים נשארים פתוחים במכוון. כניסות Type-2 בזרם הפניות צולבות מצביעות אל תוך זרמי אובייקטים דחוסים, והמנתח אינו מרחיב זרמי אובייקטים בתוך גבול האבטחה הזה, כך שהשינויים האלה מגיחים בתור prckCompressedObject עם prrCompressedObjectUnresolved, אסורים תחת P=1 ו-Indeterminate אחרת. תקציבים נוקשים של 1024 מהדורות, 1,000,000 מספרי אובייקטים ו-2,000,000 שינויים מדווחים מניבים prrResourceLimitExceeded, ושרשרת xref שבורה מניבה prrMalformedRevisionChain; שניהם מסתיימים בתור Indeterminate, לעולם לא בתור עובר

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // חלק מהסיכונים נרשמים בלי לשנות את Status, אז בדקו אותם קודם
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

הסדר בשער הזה מכוון. prrDuplicateObjectDefinition מתווסף לסט הסיכונים בלי להוריד את Status כשלעצמו, וטרנספורמציית FieldMDP שאי אפשר לפרס משפיעה על המצב רק כששדה טופס באמת משתנה, כך ששער שמביט ב-Status לבדו עלול לפספס ראיות שהדוח כבר מכיל. שימו לב גם למה שהדוח לא טוען. TPadesRevisionAnalysisReport לא אומר דבר על תקפות קריפטוגרפית של חתימת ה-CMS או על כך שתעודת החותם שורשת אל root שאתם סומכים עליו. ניתוח מהדורות עונה על השאלה מה קרה אחרי החתימה, ומקומו לצד אימות מבני ואימות אמון, לא במקומם

כתיבת ערכי seed ונעילות MDP בזמן חתימה

את אותם כללים אפשר לכתוב בזמן חתימה דרך TPadesSignatureFieldOptions, שהוא החבר FieldOptions של גם TPadesSignOptions וגם TPadesRemoteSignOptions. PDFium יכול ליצור widget אבל לא יכול לכתוב /SV, /Lock, טרנספורמציית FieldMDP או DocMDP, או את מילון ה-/Perms של הקטלוג, ולכן הכותב המצטבר משלו של הרכיב מייצר את האובייקטים האלה בתוך אותו עדכון xref כמו החתימה. FieldName קובע את שם שדה השורש, RequiredSeedValues הופך לסיביות ה-/Ff של מילון ערכי ה-seed המתואר ב-ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations ו-AcceptableCertificates מגבילים מה חותם מאוחר רשאי לבחור, LockAction עם LockFields כותב /SigFieldLock בלתי ישיר, ו-CertificationPermission מ-1 עד 3 הופך את החתימה לחתימת הסמכה. טרנספורמציות ה-DocMDP וה-FieldMDP כולן נכנסות למערך /Reference אחד על ערך החתימה, כל אחת עם /Data שמצביע על הקטלוג

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // מילוי טפסים וחתימה בלבד
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // נועל רק את השדות האלה
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

כמה פרטים קלים לפישול אם אתם אורגים את זה ביד. ה-/Perms /DocMDP של הקטלוג חייב להפנות למילון ערך החתימה, לא לאנוטציית ה-widget, והכותב משמר את ערך החתימה בתור אובייקט בלתי ישיר משלו מהסיבה הזאת. מילון /Perms קיים עשוי כבר להחזיק זכויות שימוש /UR3, ולכן הכותב מעתיק אותו ומחדיר /DocMDP במקום להחליפו, לפי מילון ההרשאות ב-ISO 32000-1 §12.8.4. מסמך שכבר נושא /DocMDP מסרב לחתימת הסמכה שנייה עם EPadesCrypto, וכך גם אפשרויות לא עקביות: נעילת Include או Exclude בלי שמות שדות, נעילת All עם רשימת שדות, attestation משפטי על חתימה שאינה הסמכה, או נקודה בשם שדה השורש. חתימה מרחוק מוסיפה כלל אחד נוסף, כי תעודת החתימה אינה ידועה כש-PreparePadesRemoteSignature רץ: הגדרת CertificateRequired שם מחייבת רשימת AcceptableCertificates מפורשת, בזמן שחתימה מקומית יכולה לחזור לתעודת החותם שנפתרה

ניתוח מהדורות משלים את ערכת הכלים של החתימות במקום להחליף אף חלק ממנה. התחילו ב-בחינת חתימות PDF ורמות PAdES עם PDFium Component כדי לקרוא את המילון ואת רמת הבסיס, הציצו במאמר על מדוע validators דוחים חתימות PAdES עבור הכשלים המבניים שקודמים לכל שאלת מהדורות, ושלבו את פסק הדין לתוך ביקורת סיכוני אבטחת PDF רחבה יותר לצד בדיקות JavaScript וקבצים מוטמעים. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions והכותב המצטבר של PAdES שמוצג כאן נשלחים עם PDFium Component עבור Delphi, C++Builder ו-Lazarus