HotPDF, רכיב ה-PDF לדלפי, דוחה עכשיו signature wrapping: החל מ-v2.759.0 גם VerifyLoadedSignatureEx וגם ה-validator לאימות באצווה דורשים שהפער בין שני מקטעי ה-/ByteRange יהיה בדיוק מחרוזת ה-hex של /Contents, מפרידים כלולים, ו-v2.761.0 מוסיפה את AddLoadedSignedSignatureField כדי שאפשר יהיה לצרף חתימה שנייה ל-PDF שכבר חתום בתור revision אינקרמנטלי נקי. שני השינויים האלה הולכים יחד, כי חתימה שנייה נכונה היא בדיוק הפריסה שה-verifier המחמיר מצפה לה
המצב שחשף את הבעיה הוא שגרתי לגמרי. ספק חותם על חוזה, ואז החוזה מגיע אל מאשר שחייב להטביע חתימה נגדית בלי להפריע לחתימה הראשונה. את ה-revision השני מצרפים אחרי הראשון, ה-/ByteRange שלו משתרע על כל הקובץ שגדל, ושתי החתימות אמורות לעבור אימות. להגיע לשם ידנית היה אומר לכתוב את המקטע האינקרמנטלי בעצמכם, ו-fixture הבדיקות שעשה בדיוק את זה התברר כמבנה signature wrapping ישר מהספר, כזה שה-verifier הישן קיבל בחפשה. אם עוד לא הכרתם את ה-API לאימות, המדריך לאימות חתימות דיגיטליות של PDF עם HotPDF מכסה את היסודות שהמאמר הזה בונה עליהם
מה בדיוק צריך להיות בפער של ה-ByteRange?
הפער חייב להכיל את ערך ה-/Contents המלא ושום דבר נוסף: ISO 32000-1 §12.8.3.3 קובע שמחרוזת ה-hex, עם המפרידים < ו-> שלה, משתלבת במדויק בתוך המרווח בין שני טווחי הבייטים, ו-ISO 32000-2 §12.8.1 נושאת את אותו כלל קדימה. Table 252 ומסמכי ה-PAdES אומרים רק שה-digest לא כולל את ערך ה-Contents, וקל להבין את זה כאילו מדובר רק בספרות ה-hex. מהדורות HotPDF הקודמות הבינו את זה ככה: PreparePDFForSigning והכנת ה-CMS בסטרימינג גיבבו גם את הסוגריים המשולשים, עם הערת מקור שהתעקשה שאת הסוגריים חייבים לכסות. validators שמשווים את הפער מול ערך החתימה מסמנים את הפריסה הזאת כ-byte range לא תקין, ולכן v2.759.0 מוציאה את שני המפרידים מחוץ לטווחים החתומים. בדיקה עצמאית מהירה על כל קובץ חתום היא להביט בשני בייטים: הבייט בהיסט ByteRange[1] חייב להיות < והבייט בהיסט ByteRange[2] - 1 חייב להיות >
למה בדיקה של פער לא-ריק מפספסת signature wrapping?
בדיקה של פער לא-ריק מוכיחה רק שמשהו הושאר בחוץ מה-digest, לא מה בדיוק, וזהו כל שטח התקיפה. את ה-placeholder של /Contents שומרים עם אלפי ספרות אפס, בזמן שמיכל CMS אמיתי בקושי ממלא אותו. תוקף יכול לסגור את מחרוזת ה-hex מוקדם בתוך ריפוד האפסים עם >, לכתוב אובייקטים חדשים או revision מזויף אל שאר המקום השמור, ולהשאיר את טווחי הבייטים בלי נגיעה. חתימת ה-CMS עדיין עוברת אימות כי כל בייט חתום נותר ללא שינוי, הטווחים עדיין מתחילים ב-0 ומסתיימים בגודל הקובץ, וה-verifier הישן של HotPDF דיווח svValid עם CoversWholeDocument מוצב כ-True. קורא PDF, מצד שני, מפרסר כל מה שיושב בחור הלא-חתום הזה
HotPDF מתייחסת עכשיו לפער כאל נתון שמאמתים בייט אחר בייט. ה-verifier קורא את הפער, מפשיט את המפרידים, מקבל רק ספרות hex בתוספת רווחי הלבן של PDF (טאב, line feed, form feed, carriage return, רווח), מפענח את הספרות ודורש שהתוצאה תהיה שווה בדיוק ל-/Contents של מילון החתימה. כל דבר אחר מוריד את התוצאה ל-svInvalidByteRange. הבדיקה רצה גם בנתיב של חתימה בודדת וגם ב-ValidateLoadedSignatureBatch, שהחזיק לוגיקת כיסוי משל עצמו והזדקק לאותו תיקון. קבצים שנוצרו על ידי HotPDF לפני v2.759.0, שהפער שלהם הכיל רק ספרות כשהסוגריים ישבו בדיוק בתוך הטווחים, עדיין עוברים אימות, כך שמסמכים ארכיוניים לא הופכים פתאום לאדומים
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
איך מוסיפים חתימה שנייה ל-PDF שכבר חתום?
פותחים את הקובץ החתום עם BeginIncrementalUpdate, קוראים ל-AddLoadedSignedSignatureField, שומרים עם SaveIncrementalUpdate, ואז חותמים על הקובץ המוכן עם פונקציית המחלקה THotPDF.SignPDFWithPFX. לפני v2.761.0 המתכון המתועד של קריאה ל-THPDFPage.AddSignedSignatureField אחרי BeginIncrementalUpdate לא יכול היה לעבוד, כי CurrentPage הוא nil במצב אינקרמנטלי ואי אפשר היה לחבר placeholder של /V אל שדה במסמך טעון. השיטה החדשה יוצרת את ה-widget בעמוד הטעון ותולה תחת /V את אותו מילון placeholder שהנתיב של מסמך חדש משתמש בו, כך ששתי דרכי החתימה חולקות סריאליזציה אחת. לגבי החתימה הראשונה עצמה, המאמר על יצירת חתימות דיגיטליות של PAdES בדלפי מדריך לאורך צינור ה-PFX
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// עמוד 0, מלבן ה-widget בנקודות, 8192 בייטים שמורים ל-CMS
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField מתנהגת במכוון בשקט רב יותר מהאחיות שלה. שאר יוצרי השדות מסוג AddLoaded* מציבים /NeedAppearances true על ה-AcroForm, מה שאומר ל-viewer לחדש את מראות השדות; במסמך חתום החידוש הזה עלול לכתוב מחדש תוכן חתום, ולכן השיטה החדשה מסירה שוב את הדגל אלא אם המקור כבר נשא אותו. /SigFlags שומר על ערכו המקורי OR 3 (SignaturesExist בתוספת AppendOnly, ISO 32000-1 Table 219). גם לא צריך לקרוא ל-MarkDirty על העמוד: הוספה אל /Annots ואל /Fields מפיצה את דגל ה-dirty אל האובייקט העקיף הבעלים, וסימון עמוד מפורש היה רק גורר מילון עמוד ללא שינוי אל תוך ה-revision החדש, מה שניתוח ה-revision ידווח אז כשינוי עמוד. ולסיום, ה-placeholder כותב את /ByteRange לפני /Contents, כי ה-patcher מאתר קודם את ה-sentinel של /ByteRange ומחפש קדימה את מחרוזת ה-hex התואמת
מה משתנה כשחותם חיצוני או HSM מייצרים את ה-CMS?
PreparePDFForSigning מחזירה שני טווחים מבוססי-אפס שהפער ביניהם הוא כל מחרוזת ה-/Contents, ו-ContentsHexStart הוא האינדקס מבוסס-1 של ספרת ה-hex הראשונה בתוך ה-AnsiString. CMS קצר יותר מרופד ב-0 בסוף, לפני ה-> הסוגר. מכיוון ש-PreparePDFForSigning מתקנת את ה-sentinel הלא-מתוקן הראשון שהיא מוצאת, מכינים בדיוק placeholder אחד לכל revision, ומעדיפים את InsertSignatureHexAt עם ההיסטים שהוחזרו על פני ה-InsertSignatureHex מבוסס-החיפוש כשחתימות מוקדמות כבר קיימות בקובץ
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // ה-helper שלכם
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// הפער הוא כל מחרוזת ה-hex: '<' סוגר את טווח 1, '>' קודם לטווח 2
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // החותם שלכם ל-CMS, hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // ה-helper שלכם
end;
מה הגבולות של הבדיקות החדשות?
svValid עדיין אומר שלמות בייטים בתוספת מפתח שתואם לתעודה המוטמעת; האמון בתעודה הזאת הוא החלטה נפרדת. את הפער מאמתים רק כשל-verifier יש את בייטי המקור, שאותם VerifyLoadedSignatureEx קורא מהקובץ הטעון ושה-overloads של TStream מקבלים מכם. עבור החתימה הראשונה בקובץ עם חתימה נגדית, CoversWholeDocument הוא נכונות False, והשאלה אם ה-revision שנוסף רק הוסיף חתימה או גם שינה עמודים היא שאלה ל-DocMDP, FieldMDP וניתוח revisions ב-HotPDF. שימו לב גם שבדיקת ה-PDF MAC המחוברת משווה היסטים מול מיקומי ה-< וה->, כך שהיא מקבלת גם את הפריסה הישנה וגם את החדשה; כלי משלכם שמקודדים באופן קשיח את ההיסטים שלפני v2.759.0 ייכשלו קודם כשיפגשו קובץ שזה עתה נחתם
אם היישום שלכם בדלפי או ב-C++Builder חותם, מוסיף חתימות נגדיות או מבקר PDFs, הדרך הבטוחה ביותר היא לתת לספרייה אחת לייצר ולאמת את אותה פריסה. HotPDF, רכיב ה-PDF הנייטיבי לדלפי מגיע עם אימות הפער המחמיר, חתימות שניות אינקרמנטליות וה-hooks של החותם החיצוני שהוצגו למעלה, הכול ברכיב אחד