PDFium VCL בודק ביטול של חתימות PDF לא מקוון ב-Windows על ידי הוספת CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY למעבר הביטול של CertGetCertificateChain, כי הדגל למטמון-בלבד שמשמש לבניית השרשרת לא מכסה אחזור של CRL או OCSP בכלל. החל מ-v3.119.1, קריאת ValidatePadesTrust לא מקוונת נשארת מחוץ לרשת, ותוצאה נקייה דורשת ראיות ביטול אמיתיות לכל תעודה. שאר הפוסט עוסק בלמה שני החצאים של המשפט הזה היו זקוקים לתיקון
התצורה שחושפת את הבעיה רגילה לחלוטין. שירות אימות רץ על מחשב Windows נעול, ה-TPadesTrustValidationOptions.NetworkPolicy הוא ptnpOffline (שהוא גם ברירת המחדל), והמפעיל מצפה שכל תשובה תגיע ממטמון התעודות המקומי. ואז מישהו מבחין בבקשות יוצאות אל נקודת הפצה של CA ביומן החומת האש, או בעבודת אצווה שנתקעת למלוא UrlRetrievalTimeoutMs של 15000 ms על כל חתימה. שום דבר בקוד לא ביקש את הרשת. Windows נסע לשם ממילא
למה בניית שרשרת לא מקוונת עדיין שולפת CRLs ב-Windows?
כי CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL מגביל רק את אחזור ה-URL שבניית השרשרת עושה: אחזורי מנפיקים של AIA, עדכוני root ו-CTL. התיעוד של Microsoft ל-CertGetCertificateChain אומר במפורש שהדגל לא חל על בדיקת ביטול. לביטול יש מתג משלו, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), ובלעדיו ספקי הביטול חופשיים להוריד CRL או לשלוח בקשת OCSP גם כשהקריאה הסובבת נראית לא מקוונת. PDFium VCL משלב כעת את הדגל הזה עם OR למעבר הביטול בכל פעם ש-OnlineRetrieval הוא False, מעל דגלי השרשרת, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT ו-CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. זה חשוב יותר מזמן תגובה: בקשת OCSP אומרת למגיב איזו תעודה אתם מסתכלים עליה, בדיוק מה ש-validator מנותק מהרשת אמור להימנע ממנו
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // False כברירת מחדל
Options.CheckTimeStamps := True;
// לא מקוון פירושו עכשיו לא מקוון גם עבור הביטול: CRL ו-OCSP ממטמון
// בלבד, ללא העלאת checkpoint של pcvstOnlineRetrieval
Report := Pdf.ValidatePadesTrust(Options);
end;
שתי בניות שרשרת, שני שדות שגיאה
ה-backend של Windows בונה את השרשרת פעמיים, ולכל בנייה יש כעת משבצת שגיאה משלה. הקריאה הראשונה ל-CertGetCertificateChain רצה בלי דגלי ביטול ומזינה את CertVerifyCertificateChainPolicy עם מדיניות הבסיס, מה שמניב TrustStatus ו-TrustError. הקריאה השנייה מוסיפה את דגלי הביטול. לפני v3.119.1, כשל של אותה קריאה שנייה כתב את GetLastError לתוך TrustError, כך ששרשרת שזה עתה אומתה כנאמנה יכלה לחזור נראית בלתי נאמנה כי ספק ביטול נתקע. התיקון קורא את GetLastError מיד ומאחסן אותו ב-TPdfCmsVerifyResult.RevocationError, ומשאיר את פסק הדין של המעבר הראשון לנפשו. וגם ערך True שחוזר מהקריאה השנייה אינו מתפרש כהצלחה; הוא רק אומר ש-Windows החזיר הקשר שרשרת שראוי לבחון
מה מסכת שגיאת אמון אפס באמת מוכיחה?
כשלעצמו, כלום. TrustStatus.dwErrorStatus מצטבר של אפס אחרי מעבר הביטול אומר שלא הודלקה סיבית שגיאה, ושרשרת שאף אלמנט בה לא נשא מידע ביטול בכלל יכולה להניב בדיוק את זה. הקוד הקודם מיפה "אין סיבית ביטול, אין סיבית נעלם, אין סיבית לא מקוון" ישר לתקף, שזו הדרך הקלאסית שבה validator מדווח על תעודה שלא נבדקה בתור נקייה. שגרת ה-ReadWinRevocationEvidence החדשה עוברת על כל שרשרת פשוטה וכל אלמנט, דוחה מבנים שה-cbSize שלהם קטן מכדי לקרוא בבטחה, ומדווח הצלחה רק כשקיים לפחות אלמנט אחד שאינו root וכל אלמנט כזה נושא CERT_REVOCATION_INFO שה-dwRevocationResult שלו הוא אפס
// מקוצר ממהלך הראיות: אלמנט נספר רק כשספק ביטול
// באמת ענה עבורו
for J := 0 to ElementCount - 1 do
begin
Element := Elements[J];
ExcludedRoot := (J = ElementCount - 1) and
((Element^.TrustStatus.dwInfoStatus and
(CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
InfoPresent := (Element^.pRevocationInfo <> nil) and
(Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
if not ExcludedRoot then
begin
Inc(RequiredCount);
if not InfoPresent or
(Element^.pRevocationInfo^.dwRevocationResult <> 0) then
Complete := False;
end;
end;
Complete := Complete and (RequiredCount > 0); // שרשרת של root בלבד לא מוכיחה דבר
תוצאת הספק נשמרת גולמית. RevocationError מחזיק את ה-DWORD של dwRevocationResult בדיוק כפי שהספק החזיר אותו, מעדיף את השגיאה מהאלמנט שנשלל כשקיים כזה (CRYPT_E_REVOKED הוא $80092010), ומסכת הסיביות של האמון לעולם אינה מתלבשת כקוד שגיאה ילידי. המיפוי ל-TPdfCmsRevocationReason מכוון גס: pcrrCertificateRevoked עם pcvsInvalid עבור שלילה מפורשת, pcrrChainUntrusted כשהשרשרת נכשלה מסיבות לא קשורות לביטול, ו-pcrrUnknown לכל השאר. Windows ייתכן שניסה OCSP ולא CRL, כך שתוצאה לא מקוונת או בלתי ידועה לא מתורגמת ל-pcrrCrlExpired. ה-backend של אימות CMS של OpenSSL יכול לעשות את ההבחנות הספציפיות ל-CRL האלה כי הוא מעריך רק CRLs שאתם נותנים לו ביד, בזמן שה-backend של SecTrust ב-macOS משאיר את השדות על pcrrNone ואפס, שפירושם "אין אבחון מפורט", לא "עבר"
איפה ההשמטה של ה-root נעצרת
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT מדלג לגיטימית על העוגן, מכיוון שאף אחד לא מפרסם CRL ששולל root מול עצמו. המלכודת היא להחליט איזה אלמנט הוא ה-root. PDFium VCL משמיט את האלמנט האחרון של שרשרת פשוטה רק כשה-dwInfoStatus שלו מסמן אותו כחתום-עצמי ($00000008) או כמהימן לפי CA במפורש ($00004000). מחשב לא מקוון לעתים קרובות לא יכול לשלוף מנפיק חסר, ולכן השרשרת מסתיימת בביניים; להתייחס לאלמנט האחרון של שרשרת חלקית כזאת בתור root היה משמיט בשקט את התעודה היחידה שסביר ביותר שסטטוס הביטול שלה נעדר מהמטמון. האלמנט הזה נשאר בסט הנדרש, אין לו תשובת ספק, והתוצאה נשארת pcvsIndeterminate
איך תוצאות הביטול של חתימה ושל חותמת זמן נשמרות נפרדות?
בתור שדות נפרדים שלעולם לא דורסים זה את זה. ה-validator של PAdES מאמת את ה-CMS המנותק של חתימת המסמך ואת ה-CMS המצורף של ה-token של חותמת הזמן מסוג RFC 3161 בשתי קריאות עצמאיות, ו-v3.119.0 נתן לכל אחד אבחון משלו על TPadesSignatureValidation: RevocationReason ו-NativeRevocationError עבור החותם, TimeStampRevocationReason ו-NativeTimeStampRevocationError עבור ה-TSA. לכן תעודת TSA שנשללה לא יכולה להתחזות לחותם שנשלל, וכשל חותמת זמן לא מוחק תוצאת שלמות שכבר התבססה. כש-CheckRevocation הוא False, או שהאימות לעולם לא הגיע לשלב הזה, השדות נשארים pcrrNone ו-0, אז תמיד קראו אותם לצד RevocationStatus ו-TimeStampRevocationStatus
for I := 0 to High(Report.Signatures) do
begin
S := Report.Signatures[I];
case S.RevocationStatus of
pcsInvalid:
Log(Format('sig %d: signer revoked, provider 0x%.8x',
[I, S.NativeRevocationError]));
pcsIndeterminate:
Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
[I, Ord(S.RevocationReason), S.NativeRevocationError]));
pcsNotChecked:
Log(Format('sig %d: revocation not checked', [I]));
end;
if S.TimeStampRevocationStatus = pcsIndeterminate then
Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
[I, S.NativeTimeStampRevocationError]));
end;
דוח הראיות עוקב אחרי אותו כלל. ייצוא ה-CSV מצרף את revocationReason, את nativeRevocationError ואת עמודות חותמת הזמן עד nativeTimeStampRevocationError בסוף סדר העמודות הקיים, כך שפרסרים ישנים ממשיכים לעבוד, וייצוא ה-JSON מוסיף שדות תואמים בלי לשנות את משמעות הישנים. אם אימות לא מקוון ממשיך לחזור בלתי החלטי, התיקון העמיד הוא במעלה הזרם: לאסוף את חומר האימות בזמן החתימה, כפי שמתואר במאמר על חתימות PDF ארוכות טווח עם חותמות זמן של RFC 3161 ו-DSS, במקום לקוות שלמכונה המאמתת יש מטמון חם
מה מטריצת הבדיקות מוכיחה ומה לא
מטריצת האימות של Windows עברה 30 תרחישים מבוקרים של chain-API ו-smoke אמיתי אחד של CMS לא מקוון על כל יעד של Delphi ו-FPC, Win32 ו-Win64. ה-smoke האמיתי מאמת חתימה תקפה תחת CA פרטי שאינו מהימן, בזמן שתוצאות נקיות ושלילה מפורשת מגיעות מתשובות CertGetCertificateChain מדומות ולא מעוגני אמון מותקנים או אחזור חי. זהו גבול כן שראוי לנסח: טיפול הדגלים, בידוד השגיאות ומהלך הראיות ננעלו, אבל מה שמטמון הביטול של מכונה מסוימת מכיל ביום נתון נשאר עניינה של Windows, ומטמון ריק מניב כעת "בלתי ידוע" נכון במקום בקשת רשת או "תקף" כוזב
טיפול הביטול הלא מקוון, האבחונים לכל שדה וייצוא הראיות הם חלק מ-API אימות חתימות ה-PDF ב-PDFium VCL עבור Delphi ו-C++Builder, לצד ה-backends של OpenSSL ו-macOS עבור פריסות חוצות פלטפורמות