מאמת דוחה חתימת PAdES בשל אחת משלוש סיבות כמעט בכל מקרה שניפינו בו באגים עם רכיב PDFium ב-Delphi: המערך /ByteRange עדיין מחזיק בסימני המקום האפסיים שלו, זמן החתימה /M אינו מחרוזת תאריך PDF מעוצבת כהלכה, או שעדכון מצטבר השמיט את רשומת /Encrypt ממסמך מוצפן. כל השלושה מייצרים קבצים שנפתחים היטב, מרונדרים היטב ונכשלים ברגע שמאמת תואם קורא את מילון החתימה
התרחיש שמניע מאמר זה הוא ספציפי למדי. אתם חותמים על חוזה עם תהליך העבודה מהמאמר על חתימת PAdES B-B, קוד הבדיקה שלכם מדווח שהחתימה נוכחת ותקינה מבחינה מבנית, כל בדיקה מקומית ירוקה — ואז הצד השני מעלה את הקובץ לפלטפורמת האימות שלו ומקבל איקס אדום. שום דבר לגבי הכשל אינו גלוי במציג, מכיוון שאף אחד משלושת הפגמים הללו אינו נוגע בתוכן הדף. הם חיים לחלוטין במילון החתימה ובסוף הקובץ (trailer), שזה בדיוק המקום שבו מאמתים מחפשים ומציגים לרוב לא
מדוע ה-ByteRange של חתימת ה-PDF שלי לא חוקי?
דחיית ByteRange פירושה כמעט תמיד שארבעת השדות מעולם לא נכתבו, ולא שהטווחים שגויים במקצת. המערך /ByteRange [A B C D] מצהיר על שני טווחים, בייטים מ-A עד A+B ובייטים מ-C עד C+D, ותקן EN 319 142-1 §6.3 (דרישה k) דורש שיחד הם יכסו את הקובץ כולו למעט מחרוזת ה-/Contents המקודדת בהקסדצימל. במספרים: A הוא 0, C >= A+B, C+D שווה לאורך הקובץ, והמרווח בין B ל-C מחזיק בדיוק את מחרוזת ה-<...> — שני בייטים של סוגריים בתוספת שני תווים הקסדצימליים לכל בייט של נתוני CMS. מאמת שקורא [0 0 0 0] מסיק שהחתימה אינה מכסה דבר ודוחה אותה, ללא קשר למידת הנכונות של מבנה ה-CMS בתוך /Contents
המכניקה של אופן התרחשות זה ראויה להבנה מכיוון שאותה תבנית קיימת בכל תהליך חתימה. חותם אינו יכול לדעת את ההיסטים הסופיים עד שהקובץ מונח, ולכן הכותב מפיק את המערך עם סימני מקום אפסיים ברוחב קבוע וממלא אותם חזרה לאחר עימוד הקובץ. בגרסאות של רכיב PDFium לפני 2.14.1, מילוי חזרה זה חיפש את תבנית סימני המקום החל ממיקום ה-/Contents — אך /ByteRange יושב לפני /Contents במילון, כך שהחיפוש לא מצא דבר וכל שלושת ההחלפות נכשלו בשקט. ה-CMS חושב על פני הטווחים הנכונים, כך שהקריפטוגרפיה הייתה תקינה; הצהרת הטווחים הללו נותרה באפס, ולכן כל מאמת תואם סירב לקבל את הקובץ. חותמות זמן של מסמכי PAdES B-LTA, המשתמשות באותו מבנה מילון, נכשלו באותו אופן — רלוונטי אם אתם בונים על חתימות B-LT ו-B-LTA לטווח ארוך. גרסה 2.14.1 ממלאת חזרה מתחילת אובייקט החתימה, והתיקון מכוסה על ידי בדיקת נסיגה (regression test) המנתחת את הקובץ שהופק ומאשרת את החישוב שלמטה
function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
Raw: TBytes;
Text: AnsiString;
P, N: Integer;
F: array[0..3] of Int64;
begin
Raw := TFile.ReadAllBytes(FileName);
SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
P := Pos('/ByteRange', Text); // first signature only
Result := P > 0;
if not Result then Exit;
Inc(P, Length('/ByteRange'));
for N := 0 to 3 do
begin
while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
F[N] := 0;
while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
begin
F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
Inc(P);
end;
end;
// EN 319 142-1 §6.3 req k: spans cover everything except /Contents
Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
(F[2] + F[3] = Int64(Length(Raw)));
end;
עשרים שורות של קוד RTL פשוט, ללא קריאות לספריות, והוא תופס את כל סוג הכשל הזה בנקודת ההפקה. אם תשמרו בדיקה אחת ממאמר זה, שמרו את הבדיקה הזו: נתחו את הפלט החתום שלכם ובדקו את שלושת השוויונים לפני שהקובץ עוזב את התהליך שלכם
מדוע מאמתים מסמנים את זמן החתימה /M כלא תקין?
מאמתים קפדניים דוחים זמן חתימה שאינו מחרוזת תאריך PDF מלאה, ושני החלקים החסרים לרוב הם הקידומת D: וסימן היסט ה-UTC. תקן ISO 32000-1 §7.9.4 מגדיר את פורמט התאריך כ-D:YYYYMMDDHHmmSS המלווה בהיסט — Z עבור UTC, או יחס חיובי/שלילי +HH'mm' אליו. ערך חשוף כמו 20260709143000 מפוענח כתאריך לקורא סלחן, אך מאמת המחיל את הדקדוק פשוטו כמשמעו רואה מחרוזת פגומה בשדה חובה ומסמן את החתימה. גרסאות של רכיב PDFium לפני 2.14.4 כתבו את רשומת /M של TPdf.SignPades ו-SignPadesBytes בדיוק בצורה חשופה זו; מאז 2.14.4 הרשומה נושאת את הקידומת D: ואת הסימן Z, כך שזמן החתימה המוצהר נקרא כ-D:20260709143000Z
שתי הערות מעשיות נקשרות לשדה זה. ראשית, כיתבו UTC וציינו זאת במפורש: חותמת זמן ללא סימן היסט מאלצת את המאמת לנחש את אזור הזמן, ותקן §7.9.4 מתייחס להיסט כחלק מהפורמט ולא כתוספת אופציונלית. שנית, זכרו מהו /M — הזמן המוצהר על ידי החותם, טענה ולא הוכחה. מאמת בודק את הפורמט שלו כאן, לא את האמת שלו; זמן מוכח מגיע מחותמת זמן של RFC 3161 ב-PAdES B-T ומעלה. עיצוב שדה נכון ובטחון בו הן החלטות נפרדות, ומאמתים אוכפים רק את הראשונה
מדוע SignPades מעלה חריגת EPadesCrypto ב-PDF מוצפן?
רכיב PDFium מסרב לחתום על מסמך מוצפן מכיוון שהחלופה היא הפקת קובץ שקוראים תואמים יהרסו בעת הפתיחה. חתימת PAdES מצורפת כעדכון מצטבר, ותקן ISO 32000-1 §7.5.6 דורש מה-trailer החדש של חלק העדכון לשאת כל רשומה של ה-trailer הקודם למעט /Prev — במסמך מוצפן זה כולל את /Encrypt. השמיטו אותו וה-trailer האחרון יצהיר שהקובץ אינו מוצפן, כך שקורא תואם ינתח את הגוף המוצפן כטקסט פשוט ויקבל זבל. גרוע מכך, הזרמים והמחרוזות שהחותם מצרף יצטרכו בעצמם להיות מוצפנים עם מפתח המסמך כדי להיות חוקיים, מה שמזרק חתימות טקסט פשוט אינו יכול לעשות. אין דרך לצרף חתימת טקסט פשוט חוקית לקובץ מוצפן, ולכן מאז גרסה 2.14.2 הפונקציות TPdf.SignPades, SignPadesBytes ו-InjectPadesDssMarkers מעלות חריגת EPadesCrypto במקום להפיק תוצאה פגומה או בלתי ניתנת לאימות
try
if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
Writeln('Signing reported failure');
except
on E: EPadesCrypto do
begin
// e.g. 'SignPadesBytes: the source document is encrypted;
// remove encryption before signing'
Writeln('Cannot sign: ', E.Message);
end;
end;
החריגה היא התוצאה הנכונה, לכן תכננו את תהליך העבודה סביבה במקום לתפוס אותה ולנסות שוב. פענחו תחילה עם סמכות בעלים, חתמו על עותק הטקסט הפשוט, ואם ערוץ ההפצה דורש הצפנה, קבלו את העובדה שהצפנה-ואז-חתימה וחתימה-ואז-הצפנה מייצרים ארטיפקטים שונים עם סיפורי אימות שונים. חתימה המחושבת על פני בייטים של טקסט פשוט אינה יכולה לשרוד הצפנה מחדש של בייטים אלה, ולכן האפשרויות הכנות הן קובץ טקסט פשוט חתום או החלטת מדיניות המתועדת לצד הקוד
כיצד שני באגים יכולים לאשר זה את זה
הפגם ב-ByteRange התחבא זמן רב מכיוון שהמאמת הפנימי שלנו היה שגוי באופן משלים, וזה הלקח החשוב ביותר במאמר זה. בדיקת הכיסוי ב-ValidatePadesCompliance דרשה מהטווח השני להתחיל בדיוק ב-A+B — מרווח אפס — מה שמסווג באופן שגוי את המבנה הסטנדרטי שבו המרווח מחזיק את מחרוזת ה-/Contents. כך שהמאמת הפנימי דחה בדיוק את המבנה התואם והמחולל הפנימי מעולם לא הפיק כזה, וצינורות החתימה-ואז-אימות הפנימיים נותרו ירוקים. כל באג מנע מחבילת הבדיקות את הדוגמה הנגדית שהייתה חושפת את הבאג השני. גרסה 2.14.1 תיקנה את שני הצדדים באותו שחרור: המחולל ממלא חזרה את כל ארבעת השדות, והמאמת מקבל C >= A+B כאשר C+D שווה לאורך הקובץ
התיקון המתודולוגי הוא אימות מצלב מול מימוש שלא אתם כתבתם. מחולל ומאמת המשתפים קוד, מחבר, או אפילו רק מודל מנטלי של הפורמט יכולים להסכים על אי-הבנה משותפת ללא הגבלת זמן; מאמת עצמאי שובר את הסימטריה. הריצו את הפלט החתום שלכם דרך בודק תאימות חיצוני אחד לפחות לפני שחרור גרסה, ושמרו על בדיקה עצמית מבנית בבנייה כקו ראשון מהיר — הפונקציה TPdf.ValidatePades, המתוארת במאמר בדיקת החתימות, מדווחת על כשל הכיסוי כבעיה מוגדרת-שם
var
R: TPadesValidationResult;
begin
Pdf.FileName := 'contract-signed.pdf';
Pdf.Active := True;
R := Pdf.ValidatePades;
if ppeiByteRangeNotCoveringFile in R.Issues then
Writeln('ByteRange does not cover the file');
if not R.IsCompliant then
Writeln('Structural issues present: do not ship this file');
end;
רשימת בדיקה לדחייה עבור פלט חתום
כאשר פלטפורמה דוחה את חתימת ה-PAdES שלכם, בדקו את הסיבות המבניות הזולות לפני שתחשדו בתעודות או בשרשראות אמון. קראו את המערך /ByteRange ואמתו את שלושת השוויונים: שדה ראשון אפס, טווח שני מתחיל במיקום או אחרי סיום הראשון, טווח שני מסתיים בדיוק בסוף הקובץ. קראו את רשומת /M ואמתו שהיא מחרוזת תאריך מלאה של סעיף §7.9.4 עם הקידומת D: וסימן היסט. ודאו שמסמך המקור לא היה מוצפן כאשר החתימה צורפה — ואם המערכת שלכם חתמה עליו בכל זאת ללא תלונה, התייחסו לשתיקה זו כאל באג במערכת. כל שלוש הבדיקות רצות על בייטים גולמיים במילי-שניות, ובניסיוננו הן מסבירות את הדחייה לעיתים קרובות בהרבה מכל סיבה קריפטוגרפית
קריאות החתימה, הבדיקה והאימות שנעשה בהן שימוש כאן — SignPades, ValidatePades ובדיקות תאימות PAdES עם חישוב ByteRange המתוקן שלהן, עיצוב התאריך והגנת ההצפנה — נשלחות עם רכיב PDFium עבור Delphi, C++Builder ו-Lazarus