PDFium VCL יכולה עכשיו להשלים שרשרת חתימת PDF ולבדוק הפקעה דרך הרשת על ה-backend של OpenSSL שלה: כש-OnlineRetrieval מופעל, ConfigureSslCmsVerifier מתקין verifier שמוריד תעודות ביניים חסרות מה-URLs של AIA caIssuers ו-CRLs מנקודות הפצת ה-CRL, בתוך תקציב קבוע של זמן, בקשות ובייטים לכל קריאת אימות. תעודות שהורדו הן חומר שרשרת בלבד. האמון עדיין מגיע אך ורק מחנות המערכת ומהעוגנים שתגדירו
הפער שהשינוי הזה סוגר מופיע בפעם הראשונה שאתם מאמתים PDFs אמיתיים על שרת לינוקס. חלק ניכר מהחותמים מטמיע ב-CMS רק את תעודת ה-leaf של עצמו, ואז OpenSSL לא מצליחה להגיע לשורש, TrustStatus חוזר לא תקף, וההפקעה אף פעם לא רצה כי השרשרת מעולם לא הפכה לאמינה. לפני v3.121.0 ה-backend של OpenSSL שמתואר ב-אימות חתימות PDF עם OpenSSL ב-PDFium VCL היה אופלייני לחלוטין ול-OnlineRetrieval לא הייתה עליו שום השפעה. דבר אחד שווה לומר כבר בפתח: מנוע ה-PDFium עצמו לא עושה בכלל אימות CMS, ולכן כל כלל שלהלן יושב בשכבת ה-PAdES של הרכיב וב-binding שלו ל-OpenSSL, שם אפשר לקרוא אותו
באיזה סדר ה-backend של OpenSSL מאמת, מוריד ובודק?
קודם שלמות, אחר כך אמון, ואז הפקעה, והרשת נגעת רק בין השלבים שזקוקים לה. VerifyCmsWithSsl בודק את חתימת ה-CMS ואת ה-signed attributes (RFC 5652) עם הערכת שרשרת מדוכאת, ואם זה נכשל הוא חוזר מיד, לפני שקיים בכלל סשן הורדה, כך שמסמך עם בייטים שבורים לא מפעיל שום בקשה יוצאת. רק אם השרשרת אז נכשלת ו-OnlineRetrieval דלוק, עוקבים אחרי הקישורים של AIA ומאמתים שוב. את נקודות הפצת ה-CRL מורידים רק אחרי שהשרשרת נהייתה אמינה, כי CRL שתלוי על נתיב לא אמין לא מוכיח דבר. שלוש פסיקות הדין נשארות נפרדות לכל אורך הדרך: חתימה תקפה עם שרשרת חלקית עדיין מדווחת כחתימה תקפה
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER; תוספת האמון היחידה
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // ptnpOffline כברירת מחדל
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // לכל קריאת אימות
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
למה תעודות שהורדו אף פעם לא נכנסות לחנות האמון?
כי ה-URLs מגיעים מהתעודה שמאמתים ברגע זה, ואת התעודה בחר החותם. הרשומה authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) היא רמז לגבי היכן נמצא המנפיק, ולא יותר. אילו מה שעונה על אותו URL היה נכנס אל חנות עוגני האמון, כל אחד היה חותם עם מפתח תוצרת בית, מכוון את ה-AIA לשרת שלו ומקבל פסיקת דין ירוקה. לכן RetrieveIntermediates מעביר כל תעודה שנפרסרה אל CMS_add1_cert, שמשבץ אותה בקבוצת הלא-אמינים של מבנה ה-CMS הזה בלבד, ו-OpenSSL עדיין חייבת לבנות ממנו נתיב אל עוגן שהגדרתם או שחנות המערכת כבר מחזיקה בו. יש גם סיבה שקטה יותר: ארגומנט התעודות של CMS_verify אינו תחליף מוכן לתעודות המוטמעות ב-CMS, ולכן ההוספה אל ה-CMS עצמו היא הדרך האמינה
לולאת האחזור מכווצת במכוון. RetrieveIntermediates רץ לכל היותר 4 סבבים, כל אחד אוסף URLs של caIssuers מכל תעודה שנמצאת עכשיו ב-CMS, ועוצה ברגע שסבב לא מוסיף דבר או שתקציב הזמן נגמר. תשובה חייבת להיפרסר עם d2i_X509 כתעודת DER אחת שצורכת את כל הגוף; בייטים עודפים נדחים, וחבילת certs-only של PKCS#7 שמוגשת מ-URL מסוג .p7c מדולגת במקום להיפרק. שיטת הגישה OCSP באותה הרחבת AIA מוזנחת, כי ה-backend הזה לא מדבר OCSP. בצד ההפקעה, RetrieveCrls קורא רק את ה-URIs של fullName בכל DistributionPoint (RFC 5280 §4.2.1.13) מתוך תעודות ה-CMS והעוגנים שהוגדרו, וה-CRLs שהורדו נכנסים אל X509_STORE שני ובלתי תלוי עם בדיקת CRL לכל השרשרת, כך ש-CRL חסר או מיושן משנה את RevocationStatus בלי לגעת אף פעם ב-TrustStatus
// מקוצר מ-VerifyCmsWithSsl (FPdfCryptoSsl.pas); הקמת BIO הושמטה.
// לכל קריאת _CMS_verify BIO תוכן טרי
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // חתימה שבורה: בכלל לא נוגעים ברשת
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, לא אמינים בלבד
// מאמתים את השרשרת שוב מול אותה חנות עוגנים
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// חנות שנייה ונפרדת: ה-CRLs שהוגדרו בתוספת המאוחזרים
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
כמה עולה קריאת אימות אחת לכל היותר?
תקרה קבועה, שאוכפת TPdfCryptoFetchSession אחד ששלבי ה-AIA וה-CRL של קריאת אימות אחת חולקים. המגבלות הן קבועים ב-FPdfCryptoHttp, לא הצעות:
- זמן:
UrlRetrievalTimeoutMs, שברירת המחדל שלו 15000 גם ב-TPdfCmsVerifyOptions.Defaultוגם ב-TPadesTrustValidationOptions.Default; סשן שנוצר עם 0 נופל בחזרה ל-30000, והשעון מתחיל לרוץ ברגע שהחתימה עברה, ומכסה כל בקשה מאוחרת - בקשות: לכל היותר 8 לכל סשן, נספרות לפני שמנסים את הטרנספורט, כך שגם מארח מת צורך משבצת
- בייטים: 1 MiB לכל תשובה ו-4 MiB בסך הכול, עם דחייה של URLs ארוכים מ-2048 תווים לפני כל חיבור
ההנהלה החשבונית מחמירה מכפי שהיא נראית בהתחלה. בייטים שהתקבלו מתשובה שנכשלה עדיין נספרים בסך הכול, כך ששרת שעונה 404 עם דף גדול לא יכול לרוקן את התקציב בחינם. הקריאה שחוצה את מגבלת התשובה מבטלת את ההורדה במקום להעביר גוף קטוע אל מפרסר ה-ASN.1, ו-HTTP 200 עם גוף ריק נדחה על הסף, כי נתיב ה-AIA היה אחרת מאנדקס את Data[0] של מערך ריק. רק URLs רגילים של http:// ו-https:// עוברים, בלי הפניות, cookies, פרטי כניסה או גילוי proxy אוטומטי, בזמן ש-HTTPS ממשיך להחזיק את בדיקות התעודה ושם המארח הרגילות שלו. ייחוד URL מתוחם במכוון לקריאה אחת: האימות הבא חייב להיות מסוגל לראות CRL שפורסם זה עתה. התקציב הוא גם לכל קריאה ולא לכל מסמך, ו-ValidatePadesTrust מאמת כל חתימה וכל אסימון חותמת זמן בנפרד, כך שהמקרה הגרוע גדל עם מספר החתימות
למה בקשת WinHTTP שפג זמנה עדיין יכולה לכתוב לזיכרון שלכם?
כי חזרה בפסק-זמן לא מבטלת את ה-callbacks שכבר בדרך. הטרנספורט של Windows מניע את WinHTTP באופן אסינכרוני וממתין על event עם הזמן שנותר לסשן, וכשההמתנה מוותרת, הבקשה עדיין יכולה להשלים קריאה ולאותת אחר כך. כוונו את הקריאה האסינכרונית אל buffer על המחסנית, וההשלמה המאוחרת הזאת תכתוב אל תוך frame שעד אז שייך לפונקציה לגמרי אחרת. התיקון הוא בעלות, לא תזמון: ה-event ו-buffer הקריאה של 16 KB יושבים ברקורד ב-heap עם שתי הפניות, אחת בידי הקורא ואחת משוחררת רק על ידי ה-callback הסופי של HANDLE_CLOSING, כך שמי שמסיים אחרון משחרר את הזיכרון
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // הקורא + ה-callback הסופי של HANDLE_CLOSING
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // קריאות אסינכרוניות נוחתות כאן, לעולם לא על המחסנית
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// בתוך status callback: HANDLE_CLOSING היא ההתראה האחרונה ש-WinHTTP
// שולחת עבור הבקשה, ולכן היא משחררת את ההפנייה השנייה
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
מה libcurl חייבת לספק על FPC Unix
resolver אסינכרוני ו-build בטוח לתהליכונים, או שהאחזור המקוון נשאר כבוי. על FPC Unix הטרנספורט עובר דרך libcurl, אותה תלות שעומדת מאחורי backend חותמות הזמן של libcurl ליעדים שאינם Windows, וה-binding מסרב לכל ספרייה שמסכת היכולות שלה חסרה את CURL_VERSION_ASYNCHDNS או את CURL_VERSION_THREADSAFE. הסיבה היא ש-CURLOPT_NOSIGNAL, שספרייה בתוך התהליך של מישהו אחר חייבת להציב, בשילוב עם resolver סינכרוני אומר שחיפוש DNS יכול פשוט לשרוד את פסק-הזמן. המלכודת השנייה היא כיבוי: curl_global_cleanup לא ממתין לתהליכוני DNS אסינכרוניים, ולכן ברגע ש-libcurl אותחלה המודול נשאר ממופה עד שהתהליך יוצא, במקום לתת לתהליכון רקע לרוץ אל תוך קוד שנפרק. כשאחת מהדרישות נכשלת, SslCapabilities.OnlineRetrieval הוא False ו-SslVerifyOptionsDiagnostics מדווח psvdOnlineRetrievalIgnored במקום להעמיד פנים שנועצה הרשת
מה התוצאה מבטיחה ומה היא לא מבטיחה
RevocationStatus תקף מה-backend הזה אומר שנמצאו, הוגדרו או הורדו CRLs עדכניים שמכסים את כל השרשרת ואף אחד מהם לא רשם תעודה מתוכה; וזהו, לא יותר. אין OCSP, כך ש-CA שמפרסם הפקעה רק דרך OCSP משאיר את התוצאה בלי גיבוי, וכישלון רשת נראה בדיוק כמו CA שלא מפרסם דבר. שימו לב גם ש-psvdNoCrlsConfigured מתאר רק את ה-CRLs שהגדרתם בעצמכם, ולכן עם אחזור מקוון זהו רמז, לא תחזית כישלון. כששרשרת ביקורת חייבת להיות ניתנת לשחזור ללא גישה לרשת, משאירים את NetworkPolicy על ברירת המחדל ptnpOffline: לא נוצר סשן הורדה וה-backend לעולם לא פותח חיבור, מה שתואם את החוזה האופלייני בצד של CryptoAPI שמתואר ב-בדיקות הפקעה אופלייניות של חתימות PDF ב-Windows
קוד האחזור, התקציבים וה-bindings של הטרנספורט מגיעים כקוד מקור עם רכיב PDFium לדלפי, כך שאפשר לוודא בדיוק לאילו URLs אימות רשאי לפנות וכמה הוא רשאי להוריד לפני שמפעילים את ptnpOnline על שרת שמטפל במסמכים לא אמינים