מאמר טכני

Backend של חותמות זמן libcurl ל-PDFium VCL ב-FPC

PDFium VCL שולח בקשות חותמת זמן של RFC 3161 דרך libcurl ביעדים שאינם Windows, כשהוא קשור בצורה דינמית לשמונה סימבולים, בשיקוף הצורה של ה-backend ל-Windows שקושר אל WinHTTP. שתי הגדרות אפשרויות מכריעות אם ההעברה אמינה תחת עומס, ואת היחידה כולה אימתו על מכונה שלא יכלה לקמפל אותה עבור פלטפורמת היעד שלה

חותמות זמן הן מה שהופך חתימה למשהו ששורד פקיעת תוקף של תעודה, וזו פעולת רשת שיושבת בתוך פעולת חתימה. השילוב הזה נותן לבחירת ההעברה משקל שהיא בדרך כלל לא מקבלת: היא רצה על worker thread, היא מדברת עם שרת שלא בשליטתך, ותקיעה שם משתקת pipeline של חתימה ולא טעינת עמוד

למה libcurl במקום לקוח ה-HTTP של FPC?

כי החלופה גוררת stack של TLS אל תוך הריפוזיטורי ואז מכריחה אותך לתחזק את זיהוי הגרסאות שלו. הדרך המובנת מאליה ב-Free Pascal היא fphttpclient עם שכבת ה-sockets של OpenSSL, והיא נכשלת בפרטים: ה-bindings של OpenSSL ב-FPC 3.2.2 מזהים OpenSSL 3.x באופן לא אמין ברוב ההפצות הנוכחיות, ו-macOS מוסיף מעל זה את ההבדלים של LibreSSL. מה שמתחיל כקריאת HTTP קטנה נהיה תחזוקה שוטפת של ABI של TLS של מישהו אחר

libcurl מסתדר לבד עם ה-backend של ה-TLS ומאמת שרשראות מול ה-trust store של הפלטפורמה, כך שהצד של הפסקל לא צריך אף אחת מאלה. שכבת הקישור היא שמונה סימבולים. המספר הזה הוא הטיעון: שטח קטן יותר בין הקוד שלך לבין תלות שזזה אומר פחות מקומות שבהם שדרוג של הפצה ישבור אותך, והוא מתלכד עם ה-backend הקיים ל-Windows, שקושר קומץ נקודות כניסה של WinHTTP באותה צורה

uses
  FPdfTsaFpc;

var
  ReqDer, RespDer: TBytes;
begin
  if not TsaHttpAvailable then
    raise Exception.Create('no HTTP transport for timestamping');

  Writeln('TSA transport: ', TsaHttpBackendName);

  ReqDer := BuildTimeStampQuery(DocumentDigest);
  if PostTimeStampQuery('https://tsa.example.org/tsr', ReqDer, RespDer) then
    AttachTimeStampToken(RespDer)
  else
    raise Exception.Create('timestamp request failed');
end;

הכרזה על פונקציית C variadic בפסקל

curl_easy_setopt ו-curl_easy_getinfo הן variadic בצד ה-C, וב-Object Pascal אין דרך להביע את זה. הגישה שעובדת היא להכריז כמה prototypes קבועים, אחד לכל מחלקת ארגומנטים, כולם מצביעים על אותו סימבול מיוצא: variant שלוקח long, variant שלוקח מצביע, וכך הלאה, כשהבחירה באתר הקריאה לפי מה שאתה בעצם מעביר

זה בטוח מסיבה ספציפית ששווה להבין ולא להעתיק. כל אחד מסוגי הארגומנטים האלה עובר ברגיסטר שלם תחת conventions הקריאה שפעילים בפלטפורמה, וזה בדיוק המקום שממנו המימוש ב-C קורא עם va_arg. התרגיל לכן מחזיק מעמד עבור שלמים, מצביעים ו-handles, והוא לא מחזיק מעמד עבור ארגומנטים של נקודה צפה, שנוסעים ברגיסטרים אחרים. אל תוסיף variant שלוקח double מתוך הנחה שהתבנית ממשיכה להחזיק גם שם

// סימבול מיוצא אחד, כמה prototypes קבועים. כל variant מעביר את הארגומנט
// שלו ברגיסטר שלם, וזה המקום שממנו הצד של ה-C קורא אותו. variant של
// נקודה צפה לא יעבוד ואסור להוסיף אותו
type
  TCurlSetOptLong = function(Handle: Pointer; Option: Integer;
    Value: NativeInt): Integer; cdecl;
  TCurlSetOptPtr  = function(Handle: Pointer; Option: Integer;
    Value: Pointer): Integer; cdecl;

var
  curl_easy_setopt_long: TCurlSetOptLong;
  curl_easy_setopt_ptr:  TCurlSetOptPtr;

שתי הגדרות שמכריעות אם הבקשה מסתיימת

הראשונה היא כותרת Expect: ריקה במפורש. libcurl מפעיל את ה-handshake של HTTP 100-continue עבור גופי בקשה מעל בערך קילובייט אחד, ושאילתת חותמת זמן עם בקשת תעודה בדרך כלל עוברת את הסף הזה. חלק משרתי TSA לעולם לא עונים על ה-continuation, ולכן הלקוח ממתין timeout מלא לפני שהוא שולח גוף שהשרת היה מקבל מיד. שליחת כותרת Expect: ריקה משביתה את ה-handshake, והבקשה עוברת ב-round trip אחד

השנייה היא CURLOPT_NOSIGNAL, שחייבת להיות מוגדרת. בלעדיה libcurl מממש את ה-timeout של פתרון השמות עם SIGALRM, והמנגנון הזה אינו thread-safe. החתימה רצה על worker thread, כך שהתנהגות ברירת המחדל היא קריסה נסתרת שמופיעה תחת concurrency ולעולם לא בבדיקה חד-חוטית. הגדרת הדגל משביתה את הנתיב מבוסס ה-signals ומשלמת על זה רק בגסות של ה-timeout של ה-resolver

שני הפגמים חולקים פרופיל שהופך אותם ליקרים לאיתור מאוחר יותר. אף אחד מהם לא מופיע בבדיקה פונקציונלית מול שרת מתנהג-היטב על חוט יחיד. שניהם מופיעים בפרודקשן, מול TSA מסוים אחד, תחת עומס. כשאתה קושר ספריית רשת, קרא מה ברירות המחדל שלה מניחות על ה-process שלך לפני שאתה מניח שהן תואמות

דיאגרמת העברת חותמות הזמן של libcurl ב-PDFium VCL שמציגה את curl_easy_setopt מוכרז כ-prototypes קבועים של long ושל מצביע בפסקל שמעבירים ארגומנטים ברגיסטרים שלמים, את כותרת ה-Expect הריקה שמשביתה את ה-handshake של HTTP 100-continue, את CURLOPT_NOSIGNAL שמסירה את נתיב ה-SIGALRM ב-worker threads, ואת תקרת התגובה ברמת ההעברה
שתי הגדרות מכריעות אם הבקשה מסתיימת: כותרת Expect ריקה מתחמקת משרתים שלעולם לא עונים על ה-continuation, ו-NOSIGNAL משאיר timeouts של פתרון שמות מחוץ לנתיב ה-signals בזמן שהחתימה רצה על worker thread

איך מאמתים קוד שהקומפיילר שלך לעולם לא יראה?

בכך שעדיין גורמים לקומפיילר לראות אותו, דרך עותק נשלט. למכונת הפיתוח כאן אין cross-compiler של Linux או של macOS, ולכן הענפים שאינם Windows של יחידת החותמות לעולם לא מגיעים למחולל הקוד בבנייה רגילה. קוד שלעולם לא מקומפל הוא קוד שנרקב בשקט: שינוי שם בטיפוס משותף, רשימת פרמטרים שהשתנתה, תלות יחידה שנוספה, ואף אחד לא שם לב במשך חודשים

הטכניקה מכנית. מעתיקים את היחידה לתיקייה זמנית, משנים לה את השם, ומחליפים כל תנאי Windows, גם את הצורה {$IFDEF MSWINDOWS} וגם את הצורה {$IF DEFINED(MSWINDOWS), בסימבול שלעולם לא מוגדר. ואז מקמפלים את העותק. כשכל 3,828 השורות מקומפלות, הוכחת שהנתיב שאינו Windows משתמש ביחידות שקיימות, קורא לפונקציות backend עם חתימות תואמות, ומפנה לטיפוסים שנמצאים ב-scope. זו לא הוכחה שההעברה עובדת, ושום דבר פרט לפלטפורמת היעד לא ייתן לך את זה. זו הוכחה שהענף לא שבור כבר עכשיו, וזהו מצב הכישלון שבאמת מצטבר

ההרגל הנלווה הוא להשאיר את יחידת ה-libcurl עצמה נקייה מ-guards של פלטפורמות, כך שהיא משתתפת בבניית ה-Windows הרגילה גם כששום דבר שם לא מפנה אליה. ה-build היומי אז ממשיך להגן על התחביר ועל הטיפוסים שלה בחינם. יחידה שמקומפלת רק על פלטפורמה שאין לך היא יחידה בלי שום קומפיילר שבודק אותה, ואותה חשיבה חלה על כל עבודת ה-cross-compiler המתוארת במלכודות cross-compiler של Delphi ו-FPC

הגבלת מה שחוזר

תגובת חותמת זמן היא מבנה DER קטן, ושום דבר בהעברה לא אוכף את זה. שרת שנפרץ, שהוגדר בצורה שגויה, או שפשוט מכוון ל-URL הלא נכון יכול להחזיר stream שרירותי, ולקוח שקורא עד שהחיבור נסגר יצבור אותו בשמחה. שתי ההעברות לכן מגבילות את התגובה, וזה המקום הנכון למגבלה: סירוב ברמת ההעברה מונע מלכתחילה הקצאה של גוף ענק, ואילו בדיקה ברמת ה-parser נורית רק אחרי שהזיכרון כבר הוקצה

אותה חשיבה חלה גם על ה-URL. ה-backend מקבל רק schemes שהוא יכול לדבר בהם באופן מועיל, כך שטעות קונפיגורציה נכשלת מיד עם הודעה ברורה במקום להימסר ל-libcurl כדי שיפרש אותה בכל דרך שתמיכת הפרוטוקולים שלו מרשה

איפה ההעברה יושבת בסיפור החתימה

חותמות זמן הן הצעד הראשון של סיפור האימות ארוך-הטווח ולא כל הסיפור. את ה-token צריך לצרף לחתימה, את חומר האימות צריך לרשום ב-document security store, וחותמות ארכיון צריך לחדש לפני שהנוכחית נחלשת. את הקשת כולה מכסה המאמר בחתימות PDF ארוכות טווח עם חותמות זמן של RFC 3161 וה-DSS

דיאגרמת PDFium VCL של בקשת חותמת זמן RFC 3161 שזורמת מ-DocumentDigest דרך BuildTimeStampQuery ו-PostTimeStampQuery על גבי libcurl אל שרת TSA, התגובה של ה-DER מוגבלת ברמת ההעברה, ואז AttachTimeStampToken שמזין את ה-DSS ואת חידוש חותמות הארכיון באימות ארוך הטווח
חותמות זמן הן הצעד הראשון של סיפור האימות ארוך הטווח: את ה-token חייבים לצרף, את חומר האימות לרשום ב-document security store, ואת חותמות הארכיון לחדש לפני שהנוכחית נחלשת

ההעברה היא גם חתיכה אחת מעמדת ניידות רחבה יותר: ה-loader של הספרייה הנטיבית שמתואר בטעינת הספרייה הנטיבית בכל יעד מטפל באותה משפחת בעיות עבור הבינארי של PDFium עצמו. בשני המקרים התבנית זהה, לקשור מספר קטן של סימבולים בצורה דינמית, לדווח בדיוק מה נכשל בקישור, ולעולם לא לתת לתלות חסרה להפוך לכישלון של link-time שמונע מהאפליקציה להתחיל

ה-backend לחותמות זמן ל-Windows וה-backend לשאר היעדים מגיעים שניהם עם רכיב ה-PDFium ל-Delphi, כשהבחירה לפי יעד ולא לפי קונפיגורציה, כך שאפליקציית Lazarus על Linux ואפליקציית Delphi על Windows מייצרות את אותה חתימה עם חותמת זמן דרך צנרת שונה