מאמר טכני

אימות חתימות PDF ב-macOS עם SecTrust ב-Delphi

רכיב PDFium ל-Delphi מאמת חתימות PDF ב-macOS דרך TPdfKeychainCmsVerifier, backend של אימות CMS שבנוי על Apple CMSDecoder ו-SecTrust במקום על CMS שעבר parsing ביד. ConfigureKeychainCmsVerifier מתקינה אותו, וקריאה יחידה ל-CMSDecoderCopySignerStatus מחזירה verdict של החתימה, handle של SecTrust וקוד תוצאה של התעודה, בדיוק צמד העמודות ש-TPdfCmsVerifyResult כבר נשאה ב-Windows

התרחיש שהכריח את העבודה משעמם ונפוץ. build של Lazarus לארכיון מסמכים רץ על Mac, פותח חוזה חתום וכל חתימה חוזרת pcsUnsupported. שום דבר לא פגום בקובץ. לאימות החתימה פשוט לא היה backend מחוץ ל-Windows, ו-validator של PAdES סירב לנחש בהיעדרו. גרסה 3.111.0 של PDFiumPas פתחה את התפר עם IPdfCmsVerifier ו-ConfigurePadesCmsVerifier; גרסה 3.113.0 מילאה אותו ב-macOS. החלק המעניין ב-port אינו הצנרת אלא שלושת המקומות שבהם ל-API של Apple אין אותה צורה כמו ל-Windows

מדוע חתימת PDF מכסה שני טווחי בתים

מפני שחתימה אינה יכולה לכסות את הבתים שמחזיקים אותה. ISO 32000-1 §12.8.1 מציבה את blob ה-CMS SignedData במחרוזת /Contents של מילון החתימה ומתארת את המידה החתומה באמצעות /ByteRange, קבוצה של זוגות offset ו-length שמכסים את כל מה שנמצא משני צדי החור הזה. שני segments, gap אחד באמצע, בכל פלטפורמה

הפלטפורמות אינן מסכימות איך segments אלה מגיעים לשכבת הקריפטו, ואי-ההסכמה עולה בזיכרון. ב-Windows, CryptVerifyDetachedMessageSignature מקבלת מערך של מצביעים ואורכים, לכן שני ה-spans נכנסים כפי שהם ב-buffer ושום דבר אינו משוכפל. CMSDecoderSetDetachedContent של Apple מקבלת CFData יחיד ואין לה צורת multi-segment, לכן ה-backend של macOS משרשרת את שני הטווחים ל-buffer רציף לפני הפענוח. זהו עותק מלא שני של הבתים החתומים. בארכיון סרוק של 400 MB מדובר בשיא זיכרון אמיתי, הוא גדל עם המסמך ולא עם החתימה ואין API חלופי לפנות אליו. תכננו את worker של האצווה בהתאם במקום לגלות זאת על מחשב לקוח

קריאה אחת ממלאת שתי עמודות של TPdfCmsVerifyResult

CMSDecoderCopySignerStatus נדיבה באופן חריג עבור entry point של Security.framework: קריאה אחת מחזירה את סטטוס החותם, SecTrustRef עבור השרשרת שבנתה ו-OSStatus להערכת התעודה. אלה נוחתים ישירות ברשומה ש-validator של PAdES כבר צורך, כאשר סטטוס החותם הופך ל-SignatureStatus, תוצאת התעודה ל-TrustStatus והערכים הגולמיים נשמרים ב-SignatureError וב-TrustError, כך ש-ticket תמיכה יכול לצטט מספר ולא תואר. Callers לעולם אינם נוגעים בעצמם ב-IPdfCmsVerifierValidatePadesCompliance ו-ValidatePadesTrust מנתבות כל אימות דרך ה-backend שמותקן, לכן הקוד שקורא TPadesSignatureValidation זהה בייט-לבייט בשתי הפלטפורמות, כפי שמתואר במדריך על בדיקת מילוני חתימות PDF ורמות PAdES ב-Delphi

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // סמלי signing ו-verification נפתרים ממסגרות שונות, לכן אחד
  // יכול להיות קיים בעוד השני אינו
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName עונה כעת 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

מדוע kCMSSignerInvalidCert מדווחת על חתימה תקפה

מפני ש-Apple מייחסת לערך הזה משמעות צרה יותר ממה ששמו מרמז: החתימה עצמה אומתה ורק שרשרת התעודות לא הצליחה להיבנות. לכן TPdfKeychainCmsVerifier ממפה את kCMSSignerInvalidCert ל-pcvsValid בעמודת SignatureStatus ומאפשרת לבעיית התעודה לצוף דרך TrustStatus, המקום שבו בעיית שרשרת שייכת. קיפול הבעיה לתוך verdict החתימה יגרום לרכיב לומר למפעיל שמסמך שלא השתנה עבר שינוי, וזו האזעקה השגויה הגרועה ביותר ש-validator של חתימות יכול להפיק

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // החתימה אומתה ורק השרשרת לא, וסטטוס האמון
  // מדווח על כך בנפרד
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

קראו את שני הסטטוסים כזוג מסודר והלוגיקה של הדיווח נכתבת מעצמה. SignatureStatus = pcvsValid יחד עם TrustStatus = pcvsInvalid מתארים מסמך שהבתים שלו שלמים ואת המנפיק שלו ה-Mac המסוים הזה אינו סומך עליו: anchor חסר ב-Keychain, intermediate שפג תוקפו או שרשרת שאי אפשר להשלים offline. זו שאלת policy של מפעיל ולא שאלת integrity של מסמך, וההבחנה היא בדיוק זו שמאחורי רוב המקרים בהערה על הסיבה ש-validators דוחים חתימות PAdES שהן תקינות קריפטוגרפית

היכן macOS באמת בודקת revocation

בתוך הערכת האמון, ולכן TPdfCmsVerifyResult.RevocationStatus עוקב אחרי TrustStatus במקום לשאת verdict משלו. SecPolicyCreateRevocation מייצרת policy, אותה policy מצטרפת ל-SecPolicyCreateBasicX509 במערך שעובר ל-CMSDecoderCopySignerStatus, ועבודת OCSP או CRL מתרחשת במקום שבו השרשרת נבנית. אין תשובה נפרדת שחוזרת, לכן דיווח עליה ידרוש להמציא אותה. המערך עצמו נושא כלל ownership קטן שכדאי לקרוא לו בשם: CFArrayCreate שומרת את שתי ה-policies, לכן שתי ההפניות המקומיות משתחררות מיד אחר כך, בעוד שמקרה של policy יחידה מדלג על המערך ומעביר את ה-policy ישירות, צורה שגם ה-API מקבלת

פעולה offline היא דגל מפורש ולא תוצאה מקרית של connectivity. כאשר TPdfCmsVerifyOptions.OnlineRetrieval הוא False, ה-backend מוסיפה kSecRevocationNetworkAccessDisabled, מגבילה את ההערכה לתשובות שכבר cached במחשב, ו-callback ה-checkpoint עדיין יורה את pcvstCryptographicSignature, pcvstChainBuild ו-pcvstRevocationCheck באותו סדר שבו ה-backend של Windows מדווח עליהן. קוד האפליקציה מגדיר את כל זה דרך רשומת options ברמה גבוהה יותר

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // responses שנשמרו במטמון בלבד
  Options.CheckTimeStamps:= True;

  Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Report:= ValidatePadesTrust(Stream, Options);
  finally
    Stream.Free;
  end;

  if Report.SignatureCount= 0 then
    Log('No signature dictionary in this document')
  else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
    Log('Document integrity failed')
  else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
    Log('Bytes intact, chain not trusted on this Mac');
end;

Get לעומת copy: השחרור שנכשל במקום אחר

SecTrustGetCertificateAtIndex נושאת סמנטיקת get וה-reference שהיא מחזירה לעולם אינו משתחרר, בעוד CMSDecoderCopySignerCert ו-SecCertificateCopyData, שיושבות כמה שורות משם באותה שגרה, נושאות סמנטיקת copy וחייבות להשתחרר. Core Foundation מקודדת את כל הכלל במילה אחת בשם הפונקציה ומערכת הטיפוסים אינה אוכפת דבר ממנו. משחררים reference שאול ושום דבר אינו משתבש באתר הקריאה: אובייקט האמון פשוט נעשה לא תקין, והקריסה מגיעה מאוחר יותר במקום שאין לו קשר נראה לעין לשרשראות תעודות

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // סמנטיקת Get: ה-reference מושאל ואינו משתחרר כאן
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // סמנטיקת Copy: זה owned וחייב לחזור
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

מה ה-verifier מבטיח כאשר שום backend אינו עונה

שהתשובה היא unsupported ולא pass שקט. כאשר ConfigurePadesCmsVerifier לא התקינה דבר וברירת המחדל של הפלטפורמה אינה יכולה לעזור, TPdfCmsVerifyResult חוזרת כאשר כל עמודה מוגדרת כ-unavailable ו-validator של PAdES ממפה זאת ל-pcsUnsupported, כך ש-build ללא backend קריפטו מדווח בכנות ולא טוען דבר על החתימה. ה-binding של macOS שמרני בכיוון הזה בכוונה: Security.framework ו-CoreFoundation מגיעות דרך dlopen ו-dlsym, לכן framework חסר או שם סמל שה-binding הזה טעה בו מופיעים כש-KeychainVerificationAvailable מחזירה False עם KeychainMissingSymbols שמציינת את האשם, ולא כ-link failure ולא כ-verdict שגוי. זו אותה עמדה של fail-closed שהרכיב נוקט כשהוא מחפש את הספרייה הטבעית, ומתוארת בקטע על טעינת ספריית PDFium native בכל יעד

אימות חתימות הוא המקום במחסנית PDF שבו להיות שגוי בשקט גרוע יותר מלא להיות זמין בקול, ו-macOS נותנת API נדיבה מספיק כדי להקל על הגעה לשני המצבים. שרשרו את טווחי הבתים וקבלו את העותק, השאירו את verdict החתימה ואת verdict השרשרת בעמודות נפרדות, כבדו את פעלי get ו-copy ותנו ל-backend חסר לומר זאת. אם אתם מעבירים workflow של מסמכי Delphi או Free Pascal ל-Mac וזקוקים לחתימה ולאימות PAdES בשני הצדדים, PDFium Delphi Component שולחת את backend של Keychain לצד זה של Windows מאחורי ממשק יחיד