רכיב 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 לעולם אינם נוגעים בעצמם ב-IPdfCmsVerifier — ValidatePadesCompliance ו-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 מאחורי ממשק יחיד