מאמר טכני

חתימות דיגיטליות PAdES ב-Delphi: חתימה ואימות עם PDFlibPas

אימות חתימת PAdES אחת פירושו בדיקה של שלושה דברים עצמאיים, וסימן ירוק בצופה מספר לכם רק על השלישי. ראשית, מערך /ByteRange חייב לכסות את הבייטים הנכונים: הטווחים שהוא מציין חייבים לשחזר את הקלט המדויק שעליו חושב עיכול ה-CMS, ללא בייטים חתומים שנשארים מחוצה להם. שנית, האישור שבתוך ה-CMS חייב לשרשר לשורש שאתם סומכים עליו ולשאת את תכונת signing-certificate החתומה ש-PAdES דורש. שלישית, אם הפרופיל טוען חותמת זמן, אסימון RFC 3161 חייב לקשור את ערך החתימה לנקודה בזמן לפני שפג תוקף האישור. Acrobat מכווץ את כל השלושה לסמל אחד; בודק תאימות שומר אותם נפרדים, וכך גם הקוד שמייצר קבצים אלה. losLab PDF Library (PDFlibPas) מספק לכם את צד החתימה, הטמעת חותמות הזמן מחדש, וקריאות הביקורת לבדיקת ByteRange לפני שאתם סומכים בו

הבחנה אחת מכשילה כמעט כל יישום PAdES ראשון, ולכן כדאי לציין אותה לפני כל קוד. חתימה שנכתבה עם /SubFilter /adbe.pkcs7.detached היא חתימת ISO 32000-1 §12.8 תקנית לחלוטין ש-Acrobat ידווח עליה כחוקית. היא גם אינה חתימת PAdES, כיוון ש-ETSI EN 319 142-1 דורש ETSI.CAdES.detached בכל רמת baseline. בודק תאימות eIDAS דוחה את הראשונה ומקבל את השנייה למרות שהקריפטוגרפיה זהה. הפרופיל הוא טענה שהמסמך מציג על עצמו, וקריאה נכונה לאותה טענה היא קריאה אחת ב-PDFlibPas

מה הופך חתימת PDF לחתימת PAdES

ETSI EN 319 142-1 מגדיר ארבע רמות baseline המוערמות על פורמט CMS. PAdES-B-B הוא נקודת הכניסה: חתימת CAdES בשדה חתימת PDF עם SubFilter מסוג ETSI.CAdES.detached ותכונת signing-certificate חתומה. PAdES-B-T מוסיף חותמת זמן RFC 3161 על ערך החתימה, ומוכיח שהחתימה קיימה לפני נקודת זמן שאיש אינו יכול לשנות לאחור. PAdES-B-LT מטמיע את האישורים, ה-CRLs ותגובות ה-OCSP הדרושות לאימות ב-Document Security Store, כך שהקובץ נשאר בר-אימות לאחר שה-CA המנפיק פורש את התשתית שלו. PAdES-B-LTA כובל את המחסנית בחותמת זמן על המסמך שמגנה מחדש על הראיות שנצברו כאשר אלגוריתמים נחלשים

PDFlibPas ממפה מושגים אלה ל-API של תהליך החתימה. סמן הפרופיל הוא SetSignProcessCustomSubFilter. אם המדיניות שלכם זקוקה לציון סוג התחייבות (הוכחת מקור, הוכחת אישור, או אחד מהמזהים האחרים של ETSI ממוספרים 1 עד 6), הדבר עובר דרך SetSignProcessCommitmentType. מדיניות חתימה מפורשת מצטרפת עם SetSignProcessSignaturePolicy, שמקבל את ה-OID של המדיניות ואת העיכול שלה. ברירת מחדל אחת ראויה לתשומת לב: כשאלגוריתם העיכול מוגדר כאוטומטי, הספרייה בוחרת SHA-256 לחתימות ETSI ו-adbe.pkcs7.detached וחוזרת ל-SHA-1 רק בנתיב ה-adbe.pkcs7.sha1 הישן. הגדירו אותו במפורש בכל מקרה. מבקרים שואלים איזה hash השתמשתם, וערך מפורש בקוד קל יותר להגן עליו מאשר ברירת מחדל שאתם צריכים לקרוא את המדריך כדי להסביר

ייצור חתימת ה-baseline

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

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('invoice.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Sig1');
    Pdf.SetSignProcessPFXFromFile(SignId, 'company.pfx', PfxPassword);
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Vienna', 'billing@example.com');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 2);          // SHA-256
    Pdf.SetSignProcessReserveContentsBytes(SignId, 8192);  // room for a timestamp later
    Pdf.EndSignProcessToFile(SignId, 'invoice-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

NewSignProcessFromFile מחזיר 0 כאשר לא ניתן לפתוח את המקור כלל. לאחר מכן, GetSignProcessResult מפריד בין מצבי הכישלון שמתרחשים בפועל בייצור: 4 פירושו סיסמת PDF שגויה, 7 סיסמת PFX שגויה, 9 קובץ אישור ללא מפתח פרטי, 10 נתיב פלט שלא ניתן לכתיבה, 11 כישלון בעת החלת בייטי החתימה. רישום הקוד המספרי לצד שם קובץ הקלט הופך כרטיס תמיכה עמום לאבחון של דקה אחת

הוספת חותמת הזמן של RFC 3161 שהספרייה לא תביא עבורכם

PDFlibPas אינה כוללת לקוח TSA, וזוהי גבול מכוון ולא פער. הספרייה מחשבת את ה-hash שרשות חותמת הזמן חייבת לחתום-נגד ומטמיעה מחדש את ה-CMS המוגדל לאחר מכן; חילופי ה-HTTP וניתוח ה-CMS ביניהם שייכים לקורא. יש סיבה טכנית קשה לפיצול. פקד CryptoAPI של Windows שמוסיף תכונות לא-חתומות, CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR, נכשל עם CRYPT_E_INVALID_INDEX על פריסת ה-SignedData המנותקת ש-PAdES משתמש בה. לכן ה-CMS המשופר חייב לבוא ממקודד CMS תחת השליטה שלכם. שום ספרייה אינה יכולה לקפל את האסימון בשקט עם קריאת מערכת אחת, וכל ספרייה שטוענת כך מבצעת את הניתוח במקום שלא ניתן לראותו

var
  Pdf: TPDFlib;
  StsId: Integer;
  HashHex, TstDer, TsAttr, AugmentedCms: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    StsId := Pdf.NewPAdESSignatureTimeStampProcessFromFile('invoice-signed.pdf', '');
    Pdf.SetPAdESSignatureTimeStampField(StsId, 'Sig1');
    Pdf.SetPAdESSignatureTimeStampDigestAlgorithm(StsId, 2);
    HashHex := Pdf.GetPAdESSignatureValueHashHex(StsId);
    // both calls below are application code: an HTTP POST to your TSA,
    // and a CMS re-encode that attaches the token as an unsigned attribute
    TstDer := RequestTimeStampToken(HashHex);
    TsAttr := Pdf.BuildPAdESSignatureTimeStampAttribute(TstDer);
    AugmentedCms := AttachUnsignedAttribute(Pdf.GetPAdESSignatureCMSBytes(StsId), TsAttr);
    Pdf.SetPAdESSignatureCMSBytes(StsId, AugmentedCms);
    Pdf.EndPAdESSignatureTimeStampProcessToFile(StsId, 'invoice-bt.pdf');
    if Pdf.GetPAdESSignatureTimeStampProcessResult(StsId) <> 1 then
      raise Exception.Create('timestamp embedding failed');
    Pdf.ReleasePAdESSignatureTimeStampProcess(StsId);
  finally
    Pdf.Free;
  end;
end;

שימו לב לקודי התוצאה כאן: 12 פירושו ששדה החתימה הנקוב אינו קיים, 11 ש-CMS הקיים לא ניתן לפענוח, ו-13 ש-CMS המוגדל אינו מתאים עוד לסמן המקום /Contents השמור. קוד 13 הוא זה שכואב, כיוון שהתיקון היחיד הוא חתימה מחדש: אסימון חותמת זמן טיפוסי עם שרשרת האישורים שלו מגיע ל-4 עד 6 KB, ושריון 8192 הבייט שנעשה בשלב B-B קיים בדיוק כדי שלשלב זה יהיה מקום לנחות

האימות מתחיל ב-ByteRange, לא בשרשרת האישורים

סימן ירוק בצופה הוא החלטת אמון מול מאגר האישורים של אותו מחשב, לא פסיקה מבנית על הקובץ. אימות תכנותי צריך להתחיל נמוך יותר, עם שאלה שעדכונים מצטברים הופכים לעדינה: אילו בייטים כל חתימה בפועל מכסה? כל שיפור שנדון כאן, בין אם חתימה שנייה, מילון DSS, או חותמת זמן על מסמך, מגיע דרך עדכון מצטבר, וכל עדכון מוסיף בייטים מחוץ ל-/ByteRange של החתימה הקודמת. אותם בייטים מוספים הם לגיטימיים. מאמת עדיין חייב לסווג אותם מול מדיניות השינוי של המסמך, ורמת DocMDP לפי שדה שבה מדיניות זו מוגדרת קריאה עם GetSignatureDocMDPLevelByName

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  I: Integer;
  B0, B1, B2, B3, FileSize: Int64;
begin
  FileSize := TFile.GetSize('invoice-bt.pdf');  // before Open: SignDoc holds a share lock
  Doc := TPDFlibSignDoc.Create;
  try
    if not Doc.Open('invoice-bt.pdf', '', False) then
      raise Exception.Create('cannot open for audit');
    Names := TStringList.Create;
    try
      Doc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
        if Doc.GetSignatureValueObjNum(Names[I]) > 0 then   // >0 means actually signed
        begin
          B0 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
          B1 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
          B2 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
          B3 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
          if (B0 = 0) and (B2 + B3 = FileSize) then
            Writeln(Names[I], ': covers the file to EOF')
          else
            Writeln(Names[I], ': earlier revision, or unexpected ByteRange layout');
        end;
    finally
      Names.Free;
    end;
    Doc.Close;
  finally
    Doc.Free;
  end;
end;

שתי מלכודות נמצאות בנתיב ביקורת זה. TPDFlibSignDoc.Open מחזיק את הקובץ עם נעילת שיתוף בלעדית, לכן מאמת שרוצה גם לגבב את בייטי הקובץ הגולמיים לאימות CMS חייב לקרוא את הקובץ לזיכרון לפני פתיחתו לביקורת. הפוך את הסדר ולקריאה תיכשל בנעילה שהגדרתם בעצמכם. המלכודת השנייה היא שקטה ולא רועשת: המקביל ב-API השטוח GetSignProcessByteRange מחזיר Integer בעוד שהיסטים הבסיסיים הם Int64, כך שמעבר ל-2 GB הקריאה השטוחה קוצצת ללא תלונה, וזו הסיבה שדוגמה זו משיגה היסטים דרך מחלקת הביקורת במקום. היעדר אחד ראוי לציון גם הוא. לשכבה השטוחה אין עטיפת VerifySignature כלל. פסיקות קריפטוגרפיות מגיעות מ-TPDFlibSignatureVerifier ברמת המחלקה, שמחזיר vsValid, vsInvalid או vsUnknown, או ממאמת חיצוני שמדיניות התאימות שלכם כבר סומכת בו

אימות לטווח ארוך: DSS, VRI וחותמת הזמן על המסמך

PAdES-B-LT קיים כיוון שתשתית ביטול היא בת-תמותה. ETSI EN 319 142-1 §5.4.2.2 מגדיר את Document Security Store: מילון ברמת המסמך הנושא אישורים, CRLs ותגובות OCSP, ובאופן אופציונלי ממופה לפי חתימה דרך רשומות VRI המקוינות ב-hash של כל /Contents של חתימה. זרימת PDFlibPas משקפת את עיצוב חותמת הזמן. NewPAdESDSSProcessFromFile פותח את התהליך; AddPAdESDSSCertificate, AddPAdESDSSCRL ו-AddPAdESDSSOCSP מקבלים blobs של DER; AddPAdESDSSVRI קושר חומר נבחר לחתימה אחת; EndPAdESDSSProcessToFile כותב הכל כעדכון מצטבר. החלק הקשה נשאר בצד שלכם. אחזור חומר הביטול, ושיפוט האם הוא טרי מספיק כדי שכדאי להטמיע אותו, הוא תפקידו של הקורא. הספרייה מבטיחה שהמילונים הם תואמי מבנה; היא אינה יכולה להבטיח שמגיב ה-OCSP שלכם אמר את האמת

נקודת הקצה של הארכיון, B-LTA, מוסיפה חותמת זמן על מסמך: שדה חתימה נפרד שסוגו הוא DocTimeStamp ולא Sig, שמיוצר דרך SetSignProcessDocTimeStamp עם אורך חתימה שמור. היא אינה מחליפה את חותמת הזמן של החתימה משלב B-T. חותמת הזמן של החתימה מוכיחה מתי חתימה פרטית אחת קיימה; חותמת הזמן על המסמך מגנה על כל הקובץ, כולל ראיות DSS, והיא האלמנט שארכיון לטווח ארוך מחדש כל כמה שנים כאשר אלגוריתמים נחלשים. פרופיל ארכיון בוגר נושא את שניהם. לקוראים שקדמו למבנים אלה, TPDFlibSignDoc.EnsurePAdESExtensions מתעד את הרחבת המפתח ESIC בקטלוג המסמך, ומכריז שהקובץ משתמש בתכונות המוגדרות על ידי ETSI

תגובה אחת לכל זה ראויה לבלימה, כיוון שהיא נראית כבאג ואינה כזה. צופה לעיתים קרובות מדווח "תוקף לא ידוע" על קובץ שמבנה ה-PAdES שלו תקין לחלוטין. אמון ומבנה הם צירים עצמאיים. הצופה פשוט אינו מסוגל לשרשר את החותם לשורש שהוא סומך בו על אותו מחשב, דבר שגרתי עם CAs פרטיים ואישורי בדיקה, גם כאשר ביקורת ה-ByteRange ואימות ה-CMS שניהם עוברים. התיקון הוא להפיץ את אישור השורש כראוי, או להעריך מול רשימות האמון של האיחוד האירופי כאשר מעמד eIDAS כשיר הוא המטרה האמיתית, ולא לגעת בקוד החתימה

לנקודת המבט של הביקורת, כלומר מניית שדות חתימה על פני קורפוס, הוצאת פריסות ByteRange ורמות DocMDP בצובר, ראו את חלק הנלווה על לוח העבודה לתאימות וחתימה. מסמכים חתומים שחייבים גם לעמוד במדיניות ארכיון שייכים לזרימת העבודה המתוארת בPDF/A ו-PDF/UA preflight ב-Delphi. תיעוד API מלא והורדות להערכה נמצאים בדף המוצר losLab PDF Library for Delphi