מאמר טכני

בניית סביבת עבודה לחתימה ותאימות ב-Delphi עם PDFlibPas

סביבת עבודה שמקשרת אימות תאימות לחתימה דיגיטלית חייבת לתאם ארבעה שלבים, לפי הסדר הזה, ולשמור אותם מקושרים לאותה קבוצת בתים לאורך כל הדרך. היא מריצה בדיקת PDF/A או PDF/UA מקדימה. היא מיישמת את התיקונים שהממצאים דורשים ושומרת גרסה מתוקנת. היא חותמת על אותה גרסה בדיוק. ואז היא פותחת מחדש את הקובץ החתום ומאשרת שהחתימה אכן מכסה אותו. הסדר אינו קוסמטי. תדלגו על הקריאה חזרה ותסמכו על נתיב הכתיבה שלכם; תניחו לבדיקה המקדימה לרוץ על הגרסה הלא-נכונה ודוח התאימות שלכם יתאר קובץ שמעולם לא שלחתם

הנקודה שבה רוב צינורות העבודה הביתיים נכשלים היא הגבול בין האימות לחתימה. הריצו אותם כשני כלים נפרדים עם שלב תיקון ביניהם — ולפחות שלוש גרסאות שונות של הקובץ ייווצרו, כל אחת עם בתים משלה. דוח הבדיקה המקדימה שתמסרו לרואה חשבון מתאר אחת מהן. החתימה מקפיאה אחרת. שום דבר בקובץ אינו מצהיר שהן אותה גרסה, ולעתים קרובות הן אינן. PDFlibPas, ספריית הפיתוח של PDF של losLab עבור Delphi ו-C++Builder, מציבה את הבדיקה המקדימה ואת חתימת PAdES מאחורי מחלקת חזית אחת, כך שהרצף כולו יכול לחיות בתהליך אחד שלעולם לא מאבד מעקב אחרי אילו בתים הוא מתמודד איתם. כל קריאה למטה קיימת בספרייה היום, וכך גם כל מלכודת שצוינה לצדה

שלוש גרסאות של מסמך אחד, ואיך הפער נפתח

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

התנהגות ספרייה אחת מהדקת משמעת זו עוד יותר. תיקוני תאימות שהתבקשו דרך SetPDFAMode או SetPDFUAMode אינם נכנסים לתוקף בעת קריאתם. הם מיושמים בזמן השמירה. תיקונים אוטומטיים כמו כפיית דגלי הדפסה של הערות שוליים או הקצאת סדר לשוניות PDF/UA נוחתים בקובץ הפלט ולא בשום מקום אחר, ולכן בדיקה שמורצת על המסמך שרק "תיקנתם" בזיכרון לא אומרת לכם כלום על הבתים הנשלחים לחתימה. שמרו תחילה, ואז בצעו בדיקה מקדימה על הקובץ השמור. מצב הזיכרון הוא טיוטה; רק הקובץ על הדיסק הוא אמיתי

בדיקה מקדימה מהדיסק, והאפס שמשמעותו שני דברים

נקודת הכניסה לבדיקה המקדימה השטוחה היא CheckFileCompliance(FileName, Password, ComplianceTest, Options). בדיקה 1 בוחרת PDF/A (ISO 19005), בדיקה 2 בוחרת PDF/UA (ISO 14289). היא פותחת את הקובץ דרך קורא הסטרימינג של הספרייה, כך שאין צורך ב-LoadFromFile תחילה, והיא מחזירה מזהה רשימת מחרוזות הנושאת ממצא אחד לכל ערך:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

המלכודת נמצאת בערך המוחזר, וזו סוג המלכודת שעוברת כל בדיקת מסלול מאושר. אפס אומר "אין ממצאים". אפס גם אומר "לא ניתן היה לפתוח את הקובץ", מכיוון שהמימוש מחזיר 0 בכל פעם שרשימת התוצאות חוזרת ריקה, כולל כשל קריאה. סביבת עבודה שקוראת 0 כאור ירוק תאשר בשמחה קובץ שתהליך אחר נעל. שילוב הקריאה עם LastErrorCode, כפי שמוצג לעיל, הוא מה שמפריד בין שני המקרים. הבודק גם פותח את הקובץ עם מצב שיתוף שמונע כתיבה, כך שאם שלב התיקון שלכם עדיין מחזיק מזהה כותב, הבדיקה המקדימה נכשלת מסיבה שאין לה קשר לתאימות ויש לה הכל עם זרם ששכחתם לשחרר

כשאדם ולא צינור עבודה צריך לקרוא את הממצאים, CreatePreflightReport מרנדר אותם כדוח קריא. ComparePreflightReports מבצע diff של שתי ריצות, שזו דרך מסודרת להראות שהתיקון ניקה את הממצאים המקוריים מבלי להכניס בשקט חדשים

חתימה על הגרסה הנבדקת עם SignProcess

ברגע שהגרסה השמורה עוברת בדיקה מקדימה וה-hash שלה מתועד, חתמו על אותו קובץ בדיוק ולא על אחר. ה-API של SignProcess נקרא כמו builder. פתחו מזהה תהליך, הגדירו אותו שורה אחר שורה, בצעו commit, ואז קראו את קוד התוצאה בחזרה

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

שתי שורות ברצף הזה נושאות יותר משקל ממה שהן נראות. SetSignProcessCustomSubFilter עם ETSI.CAdES.detached בוחרת חתימת PAdES כפי שמתוארת ב-ETSI EN 319 142-1 במקום משפחת adbe.pkcs7.detached הישנה, וזה ההבדל בין חתימה שמאמת אירופאי מקבל לבין כזו שהוא מסמן. SetSignProcessReserveContentsBytes מרפד את מציין המקום של /Contents, והגודל שתבחרו כאן הוא החלטה על העתיד: אם חותמת זמן לחתימה תגיע אי פעם, ה-CMS המורחב חייב להתאים לשטח שתרשמו עכשיו, כי מציין המקום לא יכול לצמוח מאוחר יותר מבלי לחתום מחדש על הכל. רשמו בנדיבות ותבזבזו כמה קילובתים. רשמו צר מדי וצעד חותמת הזמן ייכשל חודשים מעכשיו עם גלישה שתתקשו לקשר בחזרה לשורה הזו

GetSignProcessResult עונה עם קוד, לא בוליאני, והקודים שווים שמירה. 1 הוא הצלחה. 4 הוא סיסמת PDF שגויה, 7 סיסמת אישור שגויה, 9 PFX שאינו נושא מפתח פרטי, 11 כשל בזמן שהחתימה הוחלה. קרסו אלה לאמת/שקר ותזרקו את המידע היחיד שמבדיל בין מקרה תמיכה של סיסמה שגויה לבין מפתח ללא חלק פרטי. תעדו את ה-integer

קריאה חזרה: ביקורת הקובץ שיצרתם

שום סביבת עבודה לא צריכה לסמוך על הנתיב שכתב את הקובץ שהיא עומדת לאשר. מחלקת הביקורת TPDFlibSignDoc פותחת מחדש את הפלט החתום וקוראת את ערכי מילון החתימה ישירות מהדיסק:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Capture the size before Open: the audit object holds a share lock on the file
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 means the field is signed
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

ארגומנטי ValueKey ממופים לערכי מילון. מפתח 0 מחזיר את ה-CMS הגולמי מ-/Contents, מפתחות 2 ו-3 את שמות /Filter ו-/SubFilter, ו-11 עד 14 את ארבעת מספרי ByteRange. ערכי טקסט חוזרים דרך GetSignatureTextValueByName במקום: מפתח 0 הוא זמן החתימה המוצהר, ומפתח 5 מבדיל בין Sig רגיל לבין DocTimeStamp, מה שחשוב ברגע שמסמך נושא את שניהם

לכידת גודל הקובץ בראש הדוגמה הזו היא מהותית, לא מנהלתית. TPDFlibSignDoc.Open מחזיק את הקובץ תחת נעילת שיתוף מגבילה לכל אורך חייו, כך שכל דבר שצריך את הבתים הגולמיים (גיבוב הטווח החתום, חישוב מחדש של digest ה-CMS) חייב לקרוא את הקובץ לפני ש-Open נקרא. הדמו של WorkBench לחתימה של הספרייה עצמה קורא את כל הקובץ לזיכרון תחילה בדיוק מסיבה זו, וסביבת עבודה שמתעלמת מהסדר נכשלת לסירוגין, על כל מחשב שקורה לו להפסיד את המירוץ

אריתמטיקת ByteRange שמוכיחה כיסוי

קובץ בריא עם חתימה אחת מכיל ByteRange בצורה [0 a b c]: הכיסוי מתחיל בהיסט 0, מדלג על מציין המקום ה-hex של /Contents בין a ל-b, ואז ממשיך דרך בית b+c. כאשר b+c שווה לגודל הקובץ, החתימה מכסה הכל עד סוף הקובץ, וזו התוצאה שאתם רוצים. כאשר היא נופלת קצר, מישהו הוסיף עדכון מצטבר לאחר שהחתימה נכתבה. זה לגמרי לגיטימי לפי ISO 32000-1§12.8, שכן מילוי טפסים מאוחר, חתימה שנייה ומילון DSS מגיעים בדיוק כך. זה גם בדיוק העובדה שנתיב ביקורת צריך לתעד בזמן החתימה במקום לשחזר תחת לחץ במהלך מחלוקת

שימו לב לרוחב ה-integer בזמן שאתם עושים חשבון זה. GetSignProcessByteRange של ה-API השטוח מחזיר Integer של 32 סיביות, אך הערכים הבסיסיים הם Int64, כך שבקובץ מעל 2 GB הגישה השטוחה חותכת בשקט. השתמשו ב-TPDFlibSigner.GetByteRange של שכבת המחלקה, שמחזיר Int64, או נתחו את הערכים מ-GetSignatureValueByName כפי שקוד הביקורת לעיל עושה

מה הספרייה משאירה לכם

שתי גבולות עדיפים להיוודע בזמן עיצוב מאשר בספרינט האחרון. ה-API השטוח TPDFlib אינו נושא עטיפת אימות חתימה כלל. אימות קריפטוגרפי נמצא שכבה למטה, ב-TPDFlibSignatureVerifier, שאת VerifySignature שלו עונה תקין, לא תקין, או לא ידוע. כמו כן אין לקוח HTTP מובנה לרשויות חותמות הזמן של RFC 3161. הספרייה מחשבת את ה-hash להגשה ומשיבה את ה-CMS המוגדל ברגע שאסימון חוזר, אך נסיעת הרשת לרשות מדורגת ה-TSA היא שלכם לכתוב. שתיהן פשוטות לעטוף ומאוד לא נעימות לגלות חסרות שבוע לפני שחרור, אז עצבו אותן מהסקיצה הראשונה

שאלה אחת על תאימות שווה לסגור בבהירות, כיוון שהיא מחליטה היכן הסף האחרון ממוקם: האם הוספת חתימה שוברת PDF/A? לא כשלעצמה. החתימה מגיעה כעדכון מצטבר, ו-ISO 19005-2 ואילך מאשרים מפורשות מסמכים חתומים. התנאי הוא מראה החתימה, שמשחק לפי אותם כללים כמו כל תוכן עמוד אחר, גופנים מוטבעים ועוד ללא צבע תלוי-מכשיר. לכן הסף האחרון בסביבת העבודה הוא עוד הרצת בדיקה מקדימה אחת, הפעם על הפלט החתום. התייחסו ל-CheckFileCompliance כבדיקה המהירה בתוך הצינור ועדיין אמתו מועמדי שחרור עם כלי עצמאי כמו veraPDF, שכן validators מיישמים קבוצות כללים חופפות אך לא זהות; כאשר השניים חלוקים, טקסט הממצא מציין בדרך כלל את הסעיף לקריאה

נקודת רצף אחת נובעת מכל זה. חתימה וחותמת זמן אינן מעבר יחיד: החתימה הבסיסית נכתבת תחילה, ואז תהליך חותמת זמן נפרד מרחיב את ה-CMS בתוך מרחב /Contents השמור, וזו בדיוק הסיבה שלשורת בתי Reserve הקודמת היה כל כך הרבה משקל. עבור שכבות חותמת הזמן ואימות ארוך טווח הבנויות על סביבת עבודה זו, מדריך החתימה ואימות PAdES מוביל את החתימה מהבסיס ל-B-LT, וצד הבדיקה המקדימה מורחב במדריך הבדיקה המקדימה של PDF/A ו-PDF/UA. תיעוד API מלא והורדות לניסיון זמינים בדף המוצר PDFlibPas