מאמר טכני

PKCS#11 ב-Delphi: מלכודת CK_ULONG והאריזה

PDFiumPas חותמת מסמכי PAdES דרך טוקן PKCS#11 ב-Windows, Linux ו-macOS, ושתי עובדות פלטפורמה קובעות אם הקישור יעבוד בכלל: CK_ULONG הוא ה-unsigned long של C, לכן 4 בתים ב-Windows ו-8 בתים ב-Linux וב-macOS, ו-header-ים של PKCS#11 מחילים #pragma pack(1) רק ב-Windows, מה שמזיז כל מצביע בטבלת הפונקציות. טועים באחד מהם והמודול עדיין נטען, הקריאות עדיין מחזירות ערך והמספרים שחוזרים הם garbage. זו צורת הבאג שצריך לצפות לה. אף אחד לא מוסר לכם linker error, מפני ששום דבר אינו מקושר: המודול הוא .so, .dylib או .dll שפותחים בזמן ריצה לפי path, וכל המשטח הוא struct של מצביעי פונקציות שאתם עושים להם cast וקוראים. למהדר אין מושג איך נראה ה-C header שבצד השני. כל אי-התאמה שקטה עד שהיא קריסה

מדוע binding של PKCS#11 נכשל עם קודי CKR אקראיים במקום שגיאה נקייה

מפני שאי-התאמת ABI אינה מייצרת מצב שגיאה כלל, אלא כתובת שגויה או offset שגוי, וה-token עונה בצייתנות על השאלה שמתברר שזו הייתה. אין שכבה בין הצהרת הרשומה שלכם למודול שיכולה להבחין באי-ההסכמה. מכאן יוצאים שני מצבי כשל נפרדים. אם האריזה שגויה, ה-slot שאתם קוראים לו C_GetSlotList מכיל שישה בתים של מצביע אחד ושניים מהבא, והקריאה אליו קופצת לזיכרון unmapped או, גרוע מכך, לאמצע פונקציה אחרת. זו access violation. אם CK_ULONG ברוחב שגוי, הכתובות תקינות אך הנתונים לא: out-parameter של var Count: CK_ULONG שהוצהר ברוחב 4 בתים מקבל 8 בתים שמודול LP64 כותב אליו, ודורס בשקט את ארבעת הבתים הבאים ב-stack frame שלכם, ו-template של CK_ATTRIBUTE שבו ValueLen יושב ב-offset שגוי גורם למודול לקרוא שדה אורך מתוך מצביע ה-Value שלכם. ה-token מחזיר אז CKR_BUFFER_TOO_SMALL או CKR_ATTRIBUTE_VALUE_INVALID לגיטימיים לחלוטין עבור שאלה שמעולם לא שאלתם. הקודים האלה שולחים אנשים לחפש בתצורת token במשך שעות. הבאג נמצא ארבע שורות למעלה בהצהרת טיפוס

CK_ULONG הוא unsigned long של C ולא טיפוס ברוחב קבוע

CK_ULONG מוגדר ב-header-ים של PKCS#11 כ-unsigned long של C, כלומר הרוחב שלו עוקב אחר מודל הנתונים של הפלטפורמה ולא אחר המפרט. Windows היא LLP64, לכן unsigned long נשאר בן 32 ביט גם בתהליך של 64 ביט. Linux ו-macOS הן LP64, לכן הוא עוקב אחר המצביע והופך ל-64 ביט. זו השורה החשובה ביותר ביחידה כולה, מפני שב-PKCS#11 כמעט כל scalar הוא CK_ULONG: מזהי slots, handles של sessions, handles של objects, classes של objects, סוגי keys, סוגי attributes, סוגי mechanisms, אורכי buffers וגם ערך ההחזרה CK_RV עצמו

type
{$IFDEF MSWINDOWS}
  // Windows היא LLP64: unsigned long של C נשאר שם 32 ביט
  CK_ULONG = LongWord;
{$ELSE}
  // Linux ו-macOS הן LP64: unsigned long עוקב אחר רוחב המצביע
  CK_ULONG = PtrUInt;
{$ENDIF}
  CK_RV = CK_ULONG;
  CK_FLAGS = CK_ULONG;
  CK_SLOT_ID = CK_ULONG;
  CK_SESSION_HANDLE = CK_ULONG;
  CK_OBJECT_HANDLE = CK_ULONG;
  CK_OBJECT_CLASS = CK_ULONG;
  CK_ATTRIBUTE_TYPE = CK_ULONG;
  CK_MECHANISM_TYPE = CK_ULONG;
  PCK_ULONG = ^CK_ULONG;

הצמדת כל אחד מהטיפוסים האלה ל-CK_ULONG במקום ל-LongWord או UInt64 ישירות היא כל התרגיל. היא אומרת שה-conditional מופיע פעם אחת בדיוק. כתבו אחד מהם בצורה קונקרטית ויצרתם landmine ש-port עתידי ידרוך עליו, והוא ידרוך בדיוק במקום ששכחתם

מה pragma pack(1) עושה לטבלת הפונקציות של PKCS#11

היא מזיזה כל מצביע פונקציה ב-CK_FUNCTION_LIST, מפני שהטבלה נפתחת ב-CK_VERSION בן שני בתים. תחת alignment טבעי המהדר מכניס שישה בתים של padding אחרי הגרסה, ולכן מצביע הפונקציה הראשון נוחת ב-offset 8. תחת packing של byte אין padding, ולכן הוא נוחת ב-offset 2. כל כניסה שאחריה יורשת את אותה displacement, ולכן טעות packing אינה בעיה של שדה אחד אלא של הטבלה כולה. המלכודת היא שה-header-ים של PKCS#11 מחילים #pragma pack(1) רק ב-Windows. זה הבדל פלטפורמה ולא הבדל מודול: שתי builds של אותה ספריית vendor אינן מסכימות על כך לפי ה-host שממנו הגיעו. שימו לב גם ש-packing אינו משנה דבר במבנים שכל השדות שלהם ברוחב מצביע, וזה רובם, לכן בדיקה נאיבית שנוגעת רק ב-CK_SLOT_INFO תעבור בשמחה בזמן שהטבלה מתחתיה מוזזת בשישה בתים

{$IFDEF FPC}
  {$IFDEF MSWINDOWS}{$PACKRECORDS 1}{$ELSE}{$PACKRECORDS C}{$ENDIF}
{$ELSE}
  {$A1}
{$ENDIF}

  CK_VERSION = record
    Major: Byte;
    Minor: Byte;
  end;

  CK_ATTRIBUTE = record
    AttrType: CK_ATTRIBUTE_TYPE;
    Value: Pointer;
    ValueLen: CK_ULONG;
  end;

  CK_FUNCTION_LIST = record
    Version: CK_VERSION;      // שני בתים, והסיבה לכך שהטבלה זזה
    C_Initialize: Pointer;    // offset 2 packed, offset 8 aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... הטבלה בסדר קבוע; הצהרת הקידומת עד C_Sign
    // מספיקה כדי להגיע לכל מה שה-backend הזה קורא
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}

שלושה דברים בבלוק הזה חשובים יותר ממה שנדמה. {$PACKRECORDS C} אינו זהה ל"אין directive"; הוא אומר ל-Free Pascal לעקוב אחר כללי ה-alignment של מהדר C של הפלטפורמה, וזה בדיוק החוזה שאתם צריכים ב-Linux וב-macOS. ענף Delphi הוא {$A1} ללא תנאי מפני שבניות Delphi של PDFiumPas מכוונות ל-Windows, בעוד ש-FPC נושאת את בניות Linux ו-macOS. ושורת השחזור בסוף אינה קוסמטית: השאירו את היחידה ארוזה וכל רשומה שמוצהרת אחר כך תשנה layout בשקט, וזו בדיוק תקלה של action-at-a-distance שההקשחה של binding לרכיב PDFium נגד תקלות ABI ובטיחות זיכרון נועדה למנוע

Pkcs11AbiLayout: הפיכת ה-layout ל-assertion

Pkcs11AbiLayout מדווחת על ה-layout שה-build פתר בפועל כמחרוזת אחת שניתן לטעון, בצורה ulong=4 attr=16 pss=12 table=2. build של Windows ב-64 ביט חייב לדווח בדיוק זאת, ויעד LP64 חייב לדווח ulong=8 attr=24 pss=24 table=8. כל דבר אחר אומר שקריאה דרך טבלת הפונקציות תנחת ב-slot הלא נכון, והפונקציה קיימת כדי שבדיקה יחידתית תוכל לומר זאת בקול במקום הערה שטוענת שזה כך

function Pkcs11AbiLayout: string;
var
  Table: CK_FUNCTION_LIST;
begin
  Result := 'ulong=' + IntToStr(SizeOf(CK_ULONG)) +
    ' attr=' + IntToStr(SizeOf(CK_ATTRIBUTE)) +
    ' pss=' + IntToStr(SizeOf(CK_RSA_PKCS_PSS_PARAMS)) +
    ' table=' + IntToStr(NativeUInt(@Table.C_Initialize) - NativeUInt(@Table));
end;

// בזמן הטעינה, אחרי ש-C_GetFunctionList החזירה את הטבלה:
// גרסה לא סבירה או entry point nil אומר שהרשומה סודרה
// עם packing או רוחב CK_ULONG שגויים, לכן דחה את המודול
if (FList^.Version.Major < 2) or (FList^.Version.Major > 3) or
  not Assigned(FList^.C_Initialize) or not Assigned(FList^.C_GetSlotList) or
  not Assigned(FList^.C_Sign) then
begin
  FList := nil;
  Exit;
end;

ארבעת המספרים אינם שרירותיים. attr הוא הגודל של CK_ATTRIBUTE, שמכילה CK_ULONG, מצביע ו-CK_ULONG: 4 + 8 + 4 באריזה ב-Windows x64, ו-8 + 8 + 8 ב-aligned של LP64. pss הוא CK_RSA_PKCS_PSS_PARAMS, שלושה שדות CK_ULONG, לכן 12 או 24. table הוא ה-offset של מצביע הפונקציה הראשון, וזה הערך שתופס טעות packing ראשונה. בדיקת Delphi טוענת את המחרוזת תחת {$IFDEF MSWINDOWS}; חבילת Lazarus טוענת את אותו דבר. בדיקת שוויון אחת מכסה layout שאחרת ניתן לאמת רק בקריאת header של C לצד רשומת Pascal ובהסתמכות על עצמכם. בדיקת זמן הטעינה היא החצי השני של אותו רעיון. PDFiumPas פותרת רק את C_GetFunctionList לפי שם דרך GetProcAddress או GetProcedureAddress ולוקחת את כל נקודות הכניסה האחרות מהטבלה שהקריאה מחזירה, וזה האופן שבו מפרט הבסיס של OASIS PKCS #11 מתכוון להגיע למודול ומונע תלות בשמות סמלים של vendor. לאחר מכן היא בודקת sanity למה שחזר. major version שמחוץ ל-2 עד 3, או C_Initialize, C_GetSlotList או C_Sign שהם nil, פירושם שהרשומה אינה מיושרת והמודול מושלך במקום לקרוא דרכו

חתימה דרך הטבלה: mechanisms, DigestInfo וה-C_Sign בעל שני המעברים

ברגע שה-layout נכון עבודת החתימה קטנה, מפני שחוזה ICmsSigner ש-PDFiumPas מבקשת מ-backend למלא כולל חמש מתודות וארבע מהן רק מחזירות OIDs ומזהה חותם. רק SignSignedAttrsDigest עושה משהו: היא מקבלת digest של SHA-256 באורך 32 בתים של ה-signed attributes ומחזירה בתי חתימה. הרכבת CMS, ASN.1, חותמות זמן RFC 3161 ו-DSS/LTV כבר נעשות ואינן תלויות פלטפורמה, וזו אותה חלוקת עבודה שמאפשרת לסשני חתימת PAdES מרוחקים מול HSM או שירות key בענן להתחבר לאותו seam. שלושה פרטי mechanism יעלו לכם verification שנכשל אם תדלגו עליהם. CKM_RSA_PKCS מחיל padding של PKCS#1 v1.5 אך אינו בונה את DigestInfo, לכן ה-caller מוסיף בעצמו את prefix ה-DigestInfo של SHA-256 באורך 19 בתים מ-RFC 8017; העבירו ל-token digest גולמי ותקבלו חתימה תקינה מבנית על הדבר הלא נכון. CKM_RSA_PKCS_PSS ו-CKM_ECDSA מקבלים את ה-digest כפי שהוא, אך CKM_ECDSA עונה בזוג raw של r||s, ו-CMS זקוק ל-SEQUENCE של ECDSA-Sig-Value לפי RFC 3279 §2.2.3, לכן PDFiumPas ממירה. וגם C_Sign הוא דו-מעברי בכוונה: קוראים לו עם buffer nil כדי לשאול את ה-token מה אורך החתימה, ואז שוב עם buffer בגודל הזה

var
  Options: TPdfPkcs11Options;
  Provider: IPdfPkcs11SignerProvider;
  Slot: TPdfPkcs11Slot;
begin
  Options := TPdfPkcs11Options.Default;
  Options.ModulePath := '/usr/lib/softhsm/libsofthsm2.so';
  Options.Pin := ReadOperatorPin;
  Options.CertificateLabel := 'Signing Certificate';

  if not Pkcs11ModuleAvailable(Options.ModulePath) then
    raise Exception.Create('No usable PKCS#11 module at ' + Options.ModulePath);
  // רשום זאת לפני כל דבר אחר כאשר token מתנהג רע בפלטפורמה חדשה
  Writeln('PKCS#11 ABI layout: ' + Pkcs11AbiLayout);

  Provider := ConfigurePkcs11SignerProvider(Options);
  for Slot in Provider.EnumerateSlots do
    if Slot.TokenPresent then
      Writeln(Slot.SlotID, ' ', Slot.TokenLabel);
end;

עוד כמה דברים קטנים שכדאי לדעת לפני ה-token הראשון שלכם. מודולים נשמרים במטמון לפי path מפני ש-C_Initialize היא once per process per module, וקריאה חוזרת מחזירה CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), ש-PDFiumPas מתייחסת אליה כהצלחה מתוך ההנחה שחלק אחר של ה-host כבר אתחל את אותה ספרייה. מחרוזות token כגון תיאור ה-slot ותווית ה-token הן שדות ברוחב קבוע עם padding של spaces ולא NUL-terminated, לכן יש לקצץ אותן מהזנב. ו-CKO_CERTIFICATE הוא 1 ולא 2 — 0 הוא CKO_DATA ו-2 הוא CKO_PUBLIC_KEY. כתיבת הקבוע הזה מהזיכרון היא טעות שמפיקה תוצאת חיפוש ריקה בלי שגיאה כלל

מה מאומת והיכן ההבטחה נעצרת

היו ברורים לגבי הגבול, מפני שהוא צר יותר ממה שתיאור התכונה מרמז. מה ש-PDFiumPas מאמתת היום הוא שה-layout של ה-ABI תואם את ה-C headers שדה-אחר-שדה בשני הענפים, שמודול חסר או שאי אפשר לטעון אותו מידרדר לכשל מדווח ולא לקריסה ושגם toolchains של Delphi וגם של FPC בונים את היחידה. נתיבי ה-token האמיתיים — C_Login, חיפוש אובייקטים ו-C_Sign מול חומרה — לא הופעלו, מפני שעל host הפיתוח אין מודול PKCS#11 מותקן כלל. העלו קודם SoftHSM2 ואמתו את Pkcs11AbiLayout לפני חיבור token פיזי, כדי שבעיית ABI ובעיית token לעולם לא יצטרכו להיות מאובחנות באותו זמן. אסימטריה אחת נוספת ראויה לשם. צד החתימה כעת cross-platform; צד האימות עדיין לא. אימות CMS בתוך PDFiumPas עדיין מוגן ב-{$IFDEF MSWINDOWS} ומחזיר pcsUnsupported במקומות אחרים, ואין לו injection point של provider המקביל ל-backend החתימה. לכן שירות Linux יכול להפיק חתימת PAdES B-B על key שמוחזק ב-token ואינו יכול עדיין לבדוק את הפלט שלו באותו מחשב. תכננו את שלב האימות על Windows או על validator חיצוני עד שהפער ייסגר

הלקח מתקיים גם מעבר ל-PKCS#11. לכל רשומת Pascal שמשקפת struct של C שנארז באופן מותנה דרושים שלושה דברים: alias מותנה אחד ל-scalar שמשתנה לפי הפלטפורמה, כדי שהחלטת הרוחב תהיה במקום אחד בדיוק; directives של packing שסוגרים בסוגריים את ההצהרות ומשוחזרים אחר כך; ופונקציית runtime שמדווחת על ה-layout שנפתר כמשהו שבדיקה יכולה לטעון. הערות שטוענות שרשומה תואמת ל-header שלה אינן שוות דבר; SizeOf ו-field offset שמודפסים בזמן startup שווים הרבה. ה-backend של PKCS#11, ה-backend של CNG ושאר מחסנית החתימה נשלחים בPDFium Component עבור Delphi ו-C++Builder, שבו צנרת ה-ABI כבר מותנית כדי שהקוד שלכם יוכל להישאר בצד של ה-token של הבעיה