PDFium VCL מתייחס לאימות CMS כאל backend נתיף מאחורי הממשק IPdfCmsVerifier, כך שה-validator של PAdES יכול לרוץ על Windows דרך CryptoAPI, על macOS דרך ה-Keychain, ובכל מקום שיש בו OpenSSL דרך ConfigureSslCmsVerifier. הממשק קטן. שלוש התנהגויות של OpenSSL מתחתיו מניבות תשובות שגויות ובטוחות בעצמן אם מממשים אותו בצורה נאיבית
המניע ברור דיו ברגע שאפליקציית Delphi עוזבת את Windows. אימות חתימות הוא אחד התחומים הבודדים שבהם stack הקריפטוגרפיה של הפלטפורמה אינו פרט מימוש: הוא מחליט אילו תעודות נחשבות מהימנות, אילו אלגוריתמים קיימים, ומה פירוש ביטול תוקף. מקודדים אחד בצורה קשיחה והקוד לא נודד. מפשטים אותו גרוע וכל פלטפורמה מדווחת תשובה בצורה אחרת שהקוד שקורא לזה לא יכול להשוות
מה ההפשטה באמת צריכה לשאת
שתי צורות אימות ושלוש מסקנות בלתי תלויות. חתימת PDF היא detached: התוכן החתום הוא שני טווחי הבייטים משני צידי החור של /Contents, ולכן VerifyDetached מקבל שני מקטעים ולא buffer אחד. token של חותמת זמן הוא attached, שנושא את התוכן שלו, ולכן VerifyAttached מקבל רק את ה-DER
התוצאה מתפצלת לשלושה statuses כי הם עונים על שלוש שאלות שונות ויכולים שלא להסכים. SignatureStatus אומר אם הבייטים נחתמו על ידי המפתח בתעודת החותם. TrustStatus אומר אם התעודה הזאת שורשרה למשהו שאתה סומך עליו. RevocationStatus אומר אם התעודה הייתה עדיין בתוקף בזמן הרלוונטי. מסמך עם חתימה מושלמת מתמטית מתעודה שמעולם לא שמעת עליו הוא תקף, לא מהימן ולא מוכר, וקיפול כל זה לבוליאני אחד הוא הדרך שבה validators מסיימים לשקר למשתמשים
uses
FPdfCrypto, FPdfCryptoSsl;
var
Options: TPdfCmsVerifyOptions;
begin
if not SslAvailable then
raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER, עשוי להיות ריק
ConfigureSslCrls(LoadFreshCrls); // DER, עשוי להיות ריק
ConfigureSslCmsVerifier; // מתקין את ה-backend
Writeln('backend : ', PadesCmsVerificationBackendName);
Writeln('library : ', SslLibraryPath, ' ', SslLibraryVersion);
Writeln('ABI : ', SslAbiLayout); // ulong=<n> long=<n>
Options := TPdfCmsVerifyOptions.Default;
Options.CheckRevocation := True;
Options.CollectChainCertificates := True;
end;
SslAbiLayout נראה כמו סקרנות והוא לא. כל קוד שגיאה של OpenSSL וכל דגל store חוצה את הגבול כ-unsigned long של C, שהוא ארבעה בייטים על Windows ושמונה על Linux ו-macOS. מכריזים עליו כטיפוס קבוע של 32 ביט והקוד עובד על Windows, ואז קורא בשקט חצי ערך על LP64. דיווח הרוחבים המנוחים כמחרוזת שאפשר להציב עליה assert בבדיקה הופך משפחה שלמה של סטיות ABI בין פלטפורמות לבדיקה של שורה אחת. מי שעבר את אותה בעיה עם CK_ULONG בקישור PKCS#11 יזהה אותה מיד; הסיפור הזה נמצא באריזת struct ב-PKCS#11 ורוחב CK_ULONG
למה מעבר האימות השני רואה תוכן ריק?
כי CMS_verify קורא את ה-BIO של התוכן ה-detached עד סוף הקובץ, ו-BIO שכבר נקרא לא מגולגל בחזרה בשבילך. אימות בשני מעברים הוא עיצוב סביר, קודם החתימה הקריפטוגרפית לבדה עם הערכת השרשרת מדוכאת, ואז ההערכה המלאה, והוא נכשל בצורה מטעה בצורה יוצאת דופן אם שני המעברים חולקים BIO אחד
המעבר השני מקבל אפס בייטים של תוכן. במצב detached זו לא שגיאה, כי buffer תוכן ריק הוא קלט חוקי. ה-digest פשוט לא מתאים, והכישלון מתגלה ככישלון בניית שרשרת ולא ככישלון תוכן, מה ששולח אותך לבחון תעודות ו-trust stores בזמן שהבעיה בפועל היא מיקום ב-stream. בונים מחדש את ה-memory BIO עם BIO_new_mem_buf לכל מעבר. זה עולה הקצאה אחת ומסיר את האפשרות הזאת לגמרי
מה דגל ה-no-verify מדכא ומה לא
CMS_NO_SIGNER_CERT_VERIFY מדכא את הערכת השרשרת, לא את חיפוש תעודת החותם. בפנים OpenSSL פותר ומצרף את תעודות החותם לפני שהוא מתייעץ עם הדגל, כך שאחרי מעבר ראשון שנושא את הדגל החותם כבר זמין ואת מזהי האלגוריתמים שלו אפשר לקרוא ישר. אין צורך להריץ אימות מלא שני רק כדי להשיג את תעודת החותם, מה שהשם של הדגל מפתה אותך להניח
חוק בעלות אחד הולך עם זה. ההפניה לחותם שייכת למבנה ה-CMS ואסור לשחרר אותה בנפרד. היא תקפה כל עוד המבנה קיים, ושחרור שלה מייצר השחתה שהתסמין שלה מופיע במקום אחר לגמרי, בדרך כלל בזמן ניקוי של אובייקט לא קשור
למה הדלקת בדיקת CRL דוחה כל חתימה?
כי OpenSSL בודק CRLs רק מול מה שה-store כבר מחזיק ולא מביא כלום מעצמו. הוא לא עוקב אחרי CRL distribution points והוא לא מדבר OCSP. מגדירים X509_V_FLAG_CRL_CHECK על store בלי שום CRL בפנים וכל שרשרת נכשלת עם חוסר יכולת להשיג CRL של תעודה. התוצאה נראית כמו בדיקת ביטול תוקף שעובדת ומוצאת בעיות. זו בדיקת ביטול תוקף שלעולם לא רצה
ה-backend לכן מגדיר את הדגל רק כש-ConfigureSslCrls באמת סיפקה לפחות CRL אחד. בלי אחד, RevocationStatus חוזר כ-pcvsUnsupported, שזו הצהרה כנה שהשאלה לא נענתה. מאותה סיבה OnlineRetrieval אין לו השפעה על ה-backend הזה ולא נפלט שום checkpoint של pcvstOnlineRetrieval: אין נתיב משיכה שממנו מדווחים התקדמות
זו עמדת עיצוב ששווה להגן עליה באופן כללי. validator שלא יכול לבדוק ביטול תוקף צריך לומר זאת. דיווח על תעודה שלא נבדקה כמי שלא בוטלה הוא הדרך הנפוצה ביותר שבה כלי אימות חתימות מטעים את המשתמשים שלהם, וזו בדיוק משפחת הבלבול שנחקרת בלמה validators דוחים חתימות PAdES
// Checkpoints מאפשרים ל-UI להציג איזה שלב רץ, ואומרים לך אילו שלבים
// backend בפועל מבצע
type
TSignatureProbe = class
procedure Checkpoint(Stage: TPdfCmsVerifyStage);
end;
procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
case Stage of
pcvstCryptographicSignature: Status('checking the signature');
pcvstChainBuild: Status('building the certificate chain');
pcvstOnlineRetrieval: Status('fetching validation data');
pcvstRevocationCheck: Status('checking revocation');
end;
end;
// קוראים את שלוש המסקנות בנפרד; מותר להן שלא להסכים
if Result.SignatureStatus = pcvsValid then
case Result.TrustStatus of
pcvsValid: Report('signed and trusted');
pcvsInvalid: Report('signed, chain rejected');
pcvsUnsupported,
pcvsIndeterminate: Report('signed, trust not established');
end;
if Result.RevocationStatus = pcvsUnsupported then
Report('revocation was not checked on this backend');
קישור לספרייה שאי אפשר לנעוץ
OpenSSL שינה את השמות של ה-accessors של ה-stack בין 1.0 ל-1.1, כך שלאותה פונקציה לוגית יש שני שמות ייצוא אפשריים בהתאם ל-build של ה-host במקרה. הקישור פותר קודם את השם החדש יותר ונופל בחזרה לישן, ורושם סימבול חסר רק כשאף אחד מהם לא נפתר. זו הצורה הנכונה לכל קישור דינמי מול ספרייה שאתה לא משחרר: מעדיפים שמות נוכחיים, סובלים היסטוריים, ומדווחים רק על היעדר אמיתי
SslMissingSymbols הוא מה שהופך טעינה נכשלת לאירוע שניתן לאבחון. תוצאה לא ריקה על host שברור שיש בו libcrypto מותקן אומרת שהגרסה המותקנת ישנה יותר מה-API שה-build הזה מכוון אליו, וזו שיחת תמיכה שונה לגמרי מספרייה חסרה. ConfigureSslLibraryPath מכסה את המקרה הנפוץ השני, host עם כמה builds של OpenSSL שבו זה שעל נתיב החיפוש של ברירת המחדל הוא לא זה שאתה רוצה
בחירת backend לכל פלטפורמה
הסידור המעשי הוא לבחור באתחול ולרשום מי ענה. על Windows, ה-backend של הפלטפורמה משתלב עם מחסני התעודות שארגון כבר מנהל, מה שבדרך כלל זה בדיוק מה שרוצים. על macOS ה-backend של ה-Keychain מתאים לאותה חשיבה ומתואר באימות חתימות עם SecTrust על macOS. OpenSSL היא האפשרות הניידת, והיא גם הבחירה הנכונה כשצריך מדיניות אימות זהה על פני פלטפורמות במקום כזאת שהולכת אחרי trust store של כל פלטפורמה
בכל אחת שתתקין, רושמים את PadesCmsVerificationBackendName לצד כל מסקנה שמתעדים. תוצאת אימות שמורה בלי ה-backend שייצר אותה אינה ניתנת לשחזור אחר כך, כי שלושת ערכי ה-status משמעותם שונה בעדינות בהתאם לכמה stack ענה. שכבת בחינת החתימות מעל כל זה, כולל איך רמות PAdES מדווחות, מכוסה בבחינת חתימות דיגיטליות של PDF ורמות PAdES
כל זה מגיע כקוד מקור עם רכיב ה-PDFium ל-Delphi, וזה חשוב כאן יותר מהרגיל: עבור validator של חתימות, היכולת לקרוא בדיוק אילו דגלים backend מגדיר ואילו בדיקות הוא מדלג עליהן אינה תוספת נחמדה, היא הדרך היחידה לדעת מה וי ירוק באפליקציה שלך באמת טוען