מאמר טכני

חתימת PDF מאחסון האישורים ב-HotPDF: סדר בייטים CNG מול CAPI

HotPDF חותם על PDF מול אישור שכבר יושב באחסון האישורים של Windows על ידי מסירת התקציר (digest) ל-Windows עצמה, ו-Windows משלימה את הבקשה הזו דרך אחד משני backend-ים של מפתח פרטי: CNG, שמחזיר את חתימת ה-RSA בסדר big-endian, או ה-CSP הישן של CryptoAPI, שמחזיר אותה בסדר little-endian. בלבול בין השניים גורם לכך שחתימת ה-CMS ש-HotPDF משבץ תהיה הפוכת-בייטים ביחס ל-backend שבאמת ענה, כך שמאמת תואם מדווח על החתימה כלא תקפה למרות שבייטי המסמך כלל לא נגעו בהם

שתי בעיות לא קשורות מסתתרות מאחורי המשפט הזה, ורכיב החתימה של HotPDF מול אישור מערכת חייב לפתור את שתיהן לפני שהוא חותם על משהו בכלל. אי-ההתאמה בסדר הבייטים היא שקטה: קריאת החתימה עדיין מחזירה True, קובץ ה-PDF עדיין נפתח, והכשל מופיע רק כאשר צופה עובר על מבנה ה-CMS ודוחה אותו. הבעיה השנייה רועשת וספציפית ל-C++Builder: חצי תריסר פונקציות של crypt32 מסרבות להתקשר (link), משום שספריית הייבוא ש-RAD Studio מספקת אינה מייצאת אותן. אף אחת מהבעיות לא קיימת אם חותמים תמיד עם קובץ PFX, וזו הסיבה שהיא נוטה לתפוס מפתחים שעוברים מחתימה חד-שלבית מבוססת PFX לאישור שמחלקת ה-IT כבר התקינה בפרופיל המשתמש

בחירת אישור מהאחסון

HotPDF חושף את הנתיב הזה דרך HPDFSignPDFStreamWithSystemCertificate ו-HPDFSignPDFFileWithSystemCertificate, שניהם מונעים על ידי רשומת THPDFCertificateStoreSelector: Location (‏cslCurrentUser או cslLocalMachine), StoreName (‏'MY', אחסון האישורים האישי, כברירת מחדל), טביעת אצבע (Thumbprint) מסוג SHA-1, ודגל AllowUI. טביעת האצבע מנורמלת פנימית, כך שמקפים או רווחים שהועתקו ישירות מממשק Certificate Manager מוסרים לפני שההשוואה מתבצעת

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

AllowUI = False חשוב יותר משנראה במבט ראשון, משום שהוא ממופה ישירות אל CRYPT_ACQUIRE_SILENT_FLAG, ו-Windows מכבד את זה כפשוטו: אם המפתח הפרטי של האישור המתאים יושב על כרטיס חכם או טוקן שדורש הזנת PIN ש-Windows עדיין לא שמר במטמון, הפונקציה CryptAcquireCertificatePrivateKey נכשלת במקום לפתוח תיבת דו-שיח מתוך תהליך שאולי הוא שירות (service). הכשל הזה רועש, שגיאת EHPDFCMSError שרואים מיד, אך קל לפרש אותה בטעות כ"אישור לא נמצא" כשהסיבה האמיתית היא טוקן שממתין ל-PIN שאף אחד לא הולך להקליד

למה CNG ו-CAPI לא מסכימים על סדר הבייטים?

מי מהם עונה בפועל אינו ניחוש: CryptAcquireCertificatePrivateKey מדווחת על כך ישירות דרך פרמטר יציאה בשם KeySpec, וזהו הערך היחיד שעליו רכיב החתימה של HotPDF מתפצל. מפתח מספק אחסון מפתחות (Key Storage Provider) של CNG חוזר עם KeySpec שמוגדר לערך המשמר CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); כל ערך אחר הוא מפתח CSP קלאסי של CryptoAPI. רוב האישורים האישיים שהונפקו או יובאו על התקנת Windows עדכנית מסתיימים כ-CNG למרות שקיים עדיין שכבת תאימות (shim) ל-CSP ישן, ולכן HotPDF מבקש את CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG יחד עם CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG עוד לפני שהוא בודק איזה ערך חזר

שני ה-backend-ים לא רק קוראים לפונקציות שונות, NCryptSignHash מול מפתח CNG לעומת CryptSignHashA מול מפתח CSP; הם גם מחזירים את חתימת ה-RSA הגולמית בסדר בייטים הפוך. הפלט של CNG כבר תואם למה ש-PKCS#1 מצפה לו: מחרוזת אוקטטים בסדר big-endian, הבייט המשמעותי ביותר ראשון, בדיוק מה שהמרת I2OSP לפי RFC 8017 מייצרת ומה ש-SignerInfo של CMS (RFC 5652) דורש בשדה החתימה שלו תחת ISO 32000-1 סעיף 12.8.3. לעומת זאת, CryptSignHash של CryptoAPI מחזירה את החתימה ב-little-endian, קוריוז מתועד שמקורו באופן שבו CSP-ים קלאסיים ייצגו פנימית מספרים גדולים. דילוג על ההיפוך בנתיב ה-CAPI גורם לכך שכל בייט בחתימה יושב במקום הלא נכון; חשבון ה-RSA עדיין נכון, אבל מחרוזת האוקטטים שמאמת קורא אינה זו שמוגדרת ב-PKCS#1

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

מה לגבי callback חתימה מותאם אישית?

כל מי שעוקף את רכיב החתימה המובנה של HotPDF מול אחסון האישורים יורש את אותו כלל של סדר בייטים. HPDFCMSSignPDFStreamWithExternalSigner מקבל THPDFCMSSignDigestCallback, closure מסוג reference to function(const SignedAttributesSHA256: TBytes): TBytes, לחתימה דרך HSM, מחסנית middleware של כרטיס חכם, או כל דבר אחר שאינו אישור שאחסון האישורים של Windows יכול למסור לו ידית מפתח. יהיה אשר יהיה ה-backend שמאחורי ה-callback הזה, הבייטים שהוא מחזיר חייבים להגיע בסדר big-endian לפני ש-HotPDF משלב אותם במבנה ה-CMS

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

כדאי לומר במפורש איפה הגבול כאן: שני נתיבי החתימה המובנים של HotPDF, CNG דרך NCryptSignHash עם ריפוד PKCS#1, ו-CAPI דרך CryptSignHashA, שניהם מיועדים למפתחות RSA שחותמים על תקציר SHA-256 בן 32 בייט. אף אחד מהם אינו מנהל משא ומתן על פורמט חתימת ECDSA. אישור שהמפתח הפרטי שלו מבוסס EC דורש כותב חתימה שאתה כותב בעצמך מול HPDFCMSSignPDFStreamWithExternalSigner, שמקודד את חתימת ה-ECDSA באופן שבו CMS מצפה לו במקום להניח מחרוזת בייטים באורך קבוע של RSA, ולכן אל תצפה שרכיב החתימה המובנה מול אחסון האישורים יעשה את הדבר הנכון עבור טוקן שהונפק עם אישור מבוסס EC

למה C++Builder נכשל בקישור CertOpenStore?

משום שספריית הייבוא המוגדרת כברירת מחדל של C++Builder ב-RAD Studio, import32.lib, אינה מייצאת את CertOpenStore, וגם לא חמישה מבני משפחתה: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, ו-CryptAcquireCertificatePrivateKey. בניות Delphi לעולם לא נתקלות בבעיה הזו, משום ש-dcc32/dcc64 פותרים ייבוא סטטי מסוג external 'crypt32.dll' ישירות לתוך טבלת הייבוא של קובץ ה-PE. C++Builder שונה: מהדר Delphi מייצר קובץ .obj מסוג OMF עבור בניית החבילה, ilink32 מקשר אותו, ובנקודה הזו אותה הצהרת external היא סתם סמל בלתי פתור שממתין לספריית ייבוא בשורת הפקודה. הפניית המקשר לתיקיית psdk של Windows SDK, שבה ה-crypt32.lib המלא כן מייצא את כל שישת הסמלים, גם היא לא פותרת את הבעיה: ilink32 מקשר רק את ספריות הייבוא שנקובות בפועל בשורת הפקודה שלו, import32.lib cp32mt.lib כברירת מחדל, והוספת נתיב חיפוש לא גורמת לו למשוך משהו נוסף מהנתיב הזה. הרצת tdump מול import32.lib מאשרת את הפער ישירות, אפס פגיעות עבור CertOpenStore, מול שש פגיעות נקיות ב-crypt32.lib של ה-SDK

HotPDF פותר את זה באותה דרך שבה הוא כבר מטפל בספירת אישורים במקומות אחרים בספרייה: במקום לבקש מהמקשר את הסמלים האלה, הוא טוען אותם בזמן ריצה. רשומה פנימית בשם THPDFCryptoProcs נושאת ידית של crypt32.dll, ידית של advapi32.dll, ואחת-עשרה שדות מצביע לפונקציה; LoadCryptoProcs טוענת את שני קבצי ה-DLL ופותרת כל נקודת כניסה באמצעות GetProcAddress פעם אחת בדיוק, בתחילת HPDFSignPDFStreamWithSystemCertificate, ומעלה מיידית EHPDFCMSError אם משהו חסר במקום להיכשל מאוחר יותר עם הפרת גישה (access violation) עמוק בתוך תהליך החתימה

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

הטעינה מתבצעת פעם אחת לכל קריאה ולא באופן עצל בתוך כל פונקציית עזר, משום שה-closure שבוחר בין CNG ל-CAPI לוכד את טבלת הפונקציות הטעונה לפי ערך וצריך להישאר חי לאורך כל תהליך החתימה, כולל ה-callback אל HPDFCMSSignPDFStreamWithExternalSigner; שתי ידיות ה-DLL משוחררות בבלוק finally החיצוני ביותר ברגע שהחתימה מסתיימת או מעלה חריגה. שום דבר מכל זה לא נוגע בפני השטח הציבוריים: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate, ו-THPDFCertificateStoreSelector שומרים על אותן חתימות בדיוק שהיו להם קודם, כך שקבלת התיקון היא בנייה מחדש (rebuild) עבור קוד קורא קיים, לא שינוי קוד

מה זה לא מכסה

קבלת סדר הבייטים הנכון ופתרון הקישור ב-C++Builder מייצרים SignerInfo של CMS שמאמת יכול לנתח וחתימה שהוא יכול לבדוק אריתמטית; זה לא אומר דבר על השאלה אם אותו מאמת צריך לבטוח באישור שמאחוריה, שכן בניית שרשרת אמון, בדיקת ביטול (revocation), ומדיניות חותמת זמן הן נושאים נפרדים שמתווספים מעל דרך אפשרויות ה-CMS, לא משהו שנכונות סדר הבייטים קונה בחינם. שני פרטי תחזוקה חשובים לא פחות מהקריפטוגרפיה: ה-PCCERT_CONTEXT שמוחזר מחיפוש אישור חייב להשתחרר באמצעות CertFreeCertificateContext לפני שהאחסון נסגר, וידית מפתח CNG או CSP שנרכשה, כאשר ה-API מדווח שהקורא הוא הבעלים שלה, חייבת להשתחרר דרך הקריאה של אותו backend עצמו, לעולם לא של האחר. אם התוצאה svValid שאתה מקבל אחרי כל זה מתבררת כצרה יותר משציפית, המאמר על אימות חתימות דיגיטליות ב-PDF מפרט בדיוק מה הדגל הזה מבטיח ומה לא. מכיוון שהאישור נשאר במשמורת Windows לאורך כל התהליך כאן, חתימה מול אחסון האישורים עוקפת שטח תקיפה שלם: אין כאן קובץ PKCS#12 לפענח ואין ASN.1 לעבור בעצמך, וזו הבעיה שחיזוק ה-PKCS#12 וה-ASN.1 של HotPDF פותרת במקום זאת עבור נתיב החתימה מקובץ PFX

חתימה מול אחסון אישורים, חתימה מול PFX, ו-callback-ים לחתימה חיצונית הם שלוש דלתות אל אותו צינור CMS/PKCS#7 בתוך רכיב ה-PDF של HotPDF עבור Delphi ו-C++Builder, ובחירת הדלת הנכונה תלויה בעיקר במי מורשה להחזיק את המפתח הפרטי: התהליך שלך, קובץ PFX, או Windows עצמה