يوقّع PDFiumPas مستندات PAdES عبر token من نوع PKCS#11 على Windows وLinux وmacOS، وتحدد حقيقتان عن المنصة ما إذا كان الربط سيعمل أصلًا: فـCK_ULONG هو C unsigned long، أي 4 بايتات على Windows و8 بايتات على Linux وmacOS، كما أن رؤوس PKCS#11 تطبق #pragma pack(1) على Windows فقط، وهو ما يحرك كل مؤشر في function table. أخطئ في أي منهما، وسيظل module يُحمّل، وتظل الاستدعاءات تعيد، وتصبح الأرقام العائدة قمامة. وهذا هو شكل العيب الذي ينبغي توقعه. فلن يعطيك أحد خطأ linker، لأنه لا يوجد شيء مربوط: فالـmodule هو .so أو .dylib أو .dll تفتحه وقت التشغيل عبر path، والسطح كله struct من function pointers تحوّلها وتستدعيها. ولا يعرف المصرّف كيف كان شكل رأس C في الجهة الأخرى. وكل عدم تطابق يظل صامتًا حتى يتحول إلى crash
لماذا يفشل ربط PKCS#11 برموز CKR عشوائية بدل خطأ واضح؟
لأن عدم تطابق ABI لا ينتج حالة خطأ أصلًا، بل ينتج عنوانًا خاطئًا أو إزاحة خاطئة، ويجيب token بأمان عن أي سؤال اتضح أنه ذلك السؤال. ولا توجد طبقة بين تصريح السجل لديك وmodule تستطيع ملاحظة الاختلاف. ويخرج منه نمطان متميزان من الفشل. فإذا كان packing خاطئًا، احتوى الموضع الذي تقرؤه كـC_GetSlotList على ستة بايتات من مؤشر واحد وبايتين من التالي، ويؤدي استدعاؤه إلى قفزة في ذاكرة غير مربوطة أو، الأسوأ، إلى منتصف دالة أخرى. وهذا هو access violation. أما إذا كان عرض CK_ULONG خاطئًا، فالعناوين سليمة لكن البيانات ليست كذلك: فمعامل الخرج var Count: CK_ULONG المعلن بعرض 4 بايتات يتلقى كتابة من 8 بايتات من module ذي LP64، فيكتب فوق البايتات الأربعة التالية من إطار المكدس بصمت، كما يجعل قالب CK_ATTRIBUTE الذي تقع فيه ValueLen عند إزاحة خاطئة module يقرأ حقل طول من مؤشر Value لديك. ثم يعيد token قيمة CKR_BUFFER_TOO_SMALL أو CKR_ATTRIBUTE_VALUE_INVALID مشروعة تمامًا لسؤال لم تطرحه. وترسل هذه الرموز الناس لساعات للبحث في إعدادات token. بينما يكون العيب على بعد أربعة أسطر في تصريح نوع
CK_ULONG هو unsigned long في C لا نوع بعرض ثابت
يُعرّف CK_ULONG في رؤوس PKCS#11 على أنه unsigned long في C، ما يعني أن عرضه يتبع نموذج بيانات المنصة لا المواصفة. وWindows هو LLP64، ولذلك يبقى unsigned long بعرض 32 بت حتى في عملية 64 بت. أما Linux وmacOS فهما LP64، ولذلك يتبع عرض المؤشر ويصبح 64 بت. وهذا هو السطر الأهم في الوحدة كلها، لأن كل scalar تقريبًا في PKCS#11 هو CK_ULONG: معرّفات slots، ومقابض sessions، ومقابض objects، وفئات 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;
والهدف من جعل كل واحد من تلك الأنواع alias لـCK_ULONG لا لـLongWord أو UInt64 مباشرة هو هذا بالضبط. فهو يجعل الشرط يظهر مرة واحدة. اكتب أيًا منها بصورة ملموسة، وستكون قد زرعت لغمًا سيدوسه نقل مستقبلي، وسيدوسه في الموضع الذي نسيت فيه التعديل
ماذا يفعل pragma pack(1) بجدول دوال PKCS#11؟
يحرك كل function pointer في CK_FUNCTION_LIST، لأن الجدول يبدأ بإصدار CK_VERSION من بايتين. وفي المحاذاة الطبيعية يدرج المصرّف ستة بايتات padding بعد ذلك الإصدار، فيقع أول function pointer عند الإزاحة 8. أما في byte packing فلا يوجد padding، فيقع عند الإزاحة 2. ويرث كل إدخال لاحق الإزاحة نفسها، ولهذا فإن خطأ packing ليس مشكلة حقل واحد بل مشكلة جدول كامل. والفخ هو أن رؤوس PKCS#11 تطبق #pragma pack(1) على Windows فقط. إنه اختلاف منصة لا اختلاف module: فبناءان من مكتبة المورد نفسها يختلفان في ذلك بحسب المضيف الذي أتيا منه. لاحظ أيضًا أن 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; // الإزاحة 2 مع packing، والإزاحة 8 مع المحاذاة
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 باتباع قواعد المحاذاة الخاصة بمصرّف C للمنصة، وهو العقد الذي تحتاجه بالضبط على Linux وmacOS. أما فرع Delphi فهو {$A1} غير مشروط لأن بنى PDFiumPas في Delphi تستهدف Windows، بينما يحمل FPC بنى Linux وmacOS. وسطر الاستعادة في الأسفل ليس تجميليًا: اترك الوحدة packed، وسيتغير layout بصمت لكل سجل معلن بعد هذه النقطة أيضًا، وهو بالضبط نوع عيب الفعل عن بُعد الذي يهدف تقوية ربط مكوّن PDFium ضد عيوب ABI وسلامة الذاكرة إلى إزالته
Pkcs11AbiLayout: تحويل layout إلى تأكيد
يعرض Pkcs11AbiLayout layout الذي حله البناء فعلًا كسلسلة واحدة يمكن التحقق منها بالشكل ulong=4 attr=16 pss=12 table=2. ويجب أن يبلغ بناء Windows ذي 64 بت ذلك بالضبط، بينما يجب أن يبلغ هدف LP64 ulong=8 attr=24 pss=24 table=8. وأي شيء آخر يعني أن الاستدعاء عبر function table سيصل إلى 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 الجدول:
// يعني إصدار غير معقول أو نقطة دخول nil أن السجل صيغ
// بالـpacking أو عرض CK_ULONG الخطأ، ولذلك ارفض module
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 مع packing في Windows x64، و8 + 8 + 8 مع المحاذاة في LP64. وpss هو CK_RSA_PKCS_PSS_PARAMS، بثلاثة حقول CK_ULONG، أي 12 أو 24. أما table فهي إزاحة أول function pointer، وهي القيمة التي تلتقط خطأ packing أولًا. وتؤكد حالة اختبار Delphi السلسلة تحت {$IFDEF MSWINDOWS}، كما تؤكد مجموعة Lazarus الشيء نفسه. ويغطي فحص مساواة واحد layout لا يمكن التحقق منه لولا ذلك إلا بقراءة رأس C بجانب سجل Pascal والثقة بنفسك. وفحص وقت التحميل هو النصف الثاني من الفكرة نفسها. فـPDFiumPas يحل C_GetFunctionList وحدها بالاسم عبر GetProcAddress أو GetProcedureAddress، ويأخذ كل نقطة دخول أخرى من الجدول الذي يعيده ذلك الاستدعاء، وهي الطريقة التي تقصد مواصفة OASIS PKCS #11 الأساسية الوصول بها إلى module، كما تتجاوز تسمية الرموز الخاصة بكل مورد. ثم تفحص ما عاد. فالإصدار الرئيسي خارج النطاق 2 إلى 3، أو كون C_Initialize أو C_GetSlotList أو C_Sign هو nil، يعني أن السجل غير محاذى، فيُسقط module بدل استدعائه عبره
التوقيع عبر الجدول: mechanisms وDigestInfo وC_Sign ذي المرحلتين
عندما يصبح layout صحيحًا، يكون عمل التوقيع صغيرًا، لأن عقد ICmsSigner الذي تطلب PDFiumPas من backend تحقيقه يضم خمس دوال، أربع منها تعيد OIDs ومعرّف الموقع فقط. ولا تفعل SignSignedAttrsDigest شيئًا سوى أخذ SHA-256 digest ذي 32 بايتًا للسمات الموقعة وإعادة بايتات التوقيع. أما تجميع CMS وASN.1 ووسم RFC 3161 الزمني وDSS/LTV فكلها مستقلة عن المنصة ومنجزة أصلًا، وهو تقسيم العمل نفسه الذي يتيح لـجلسات توقيع PAdES البعيدة مقابل HSM أو خدمة مفتاح سحابية الدخول من الوصلة نفسها. وستكلفك ثلاثة تفاصيل عن mechanisms تحقق فاشلة إن تجاوزتها. إذ تطبق CKM_RSA_PKCS حشو PKCS#1 v1.5 لكنها لا تبني DigestInfo، ولذلك يضيف المستدعي بادئة DigestInfo الخاصة بـSHA-256 ذات 19 بايتًا من RFC 8017 بنفسه؛ مرر digest خامًا إلى token، وستحصل على توقيع سليم شكليًا على الشيء الخطأ. وتأخذ CKM_RSA_PKCS_PSS وCKM_ECDSA digest كما هو، لكن CKM_ECDSA يجيب بزوج r||s خام، وتحتاج CMS إلى SEQUENCE من نوع ECDSA-Sig-Value حسب RFC 3279 §2.2.3، ولذلك تحوله PDFiumPas. أما C_Sign فهو ذو مرحلتين عن قصد: استدعِه مع buffer بقيمة nil لتسأل token عن طول التوقيع، ثم استدعِه مرة ثانية بمخزن بهذا الحجم
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. تُخزن modules مؤقتًا حسب path لأن C_Initialize يحدث مرة واحدة لكل عملية ولكل module، ويعيد الاستدعاء المتكرر CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190)، وتتعامل PDFiumPas معه كنجاح على افتراض أن جزءًا آخر من المضيف هيّأ المكتبة نفسها. وتكون سلاسل token مثل وصف slot وlabel token حقولًا ثابتة العرض مملوءة من الذيل، لا منتهية بـNUL، ولذلك يجب قصها من النهاية. كما أن CKO_CERTIFICATE قيمتها 1 لا 2 — فـ0 هي CKO_DATA و2 هي CKO_PUBLIC_KEY. وكتابة هذه القيمة من الذاكرة خطأ ينتج نتيجة بحث فارغة من دون أي خطأ على الإطلاق
ما الذي يجري التحقق منه، وأين يتوقف الضمان؟
كن واضحًا بشأن الحدود، لأنها أضيق مما يوحي به وصف الميزة. ما يجري التحقق منه في PDFiumPas اليوم هو أن layout الخاص بـABI يطابق رؤوس C حقلًا بحقل في الفرعين، وأن module الغائب أو غير القابل للتحميل يتحول إلى فشل مُبلغ عنه بدل crash، وأن سلسلتي أدوات Delphi وFPC تبنيان الوحدة. أما مسارات token الحقيقية — C_Login والبحث عن objects وC_Sign مقابل العتاد — فلم تُشغّل، لأن مضيف التطوير لا يملك module لـPKCS#11 مثبتًا أصلًا. ابدأ بـSoftHSM2 وتحقق من Pkcs11AbiLayout قبل توصيل token فعلي، حتى لا تضطر إلى تشخيص مشكلة ABI ومشكلة token في الوقت نفسه. ويستحق عدم تماثل آخر تسمية. فجانب التوقيع أصبح متعدد المنصات، أما جانب التحقق فلم يصبح كذلك. إذ لا يزال التحقق من CMS داخل PDFiumPas محروسًا بـ{$IFDEF MSWINDOWS} ويعيد pcsUnsupported في غيرها، ولا يملك نقطة حقن provider مكافئة لـbackend الموقع. ولذلك يستطيع service على Linux إنتاج توقيع PAdES B-B فوق مفتاح يحمله token ولا يستطيع بعد فحص ناتجه على الآلة نفسها. خطط لخطوة التحقق على Windows أو عبر validator خارجي حتى تُغلق الفجوة
والدرس يتجاوز PKCS#11. فأي سجل Pascal يعكس struct C مشروط packing يحتاج إلى ثلاثة أشياء: alias شرطي واحد للـscalar المتغير حسب المنصة حتى يوجد قرار العرض في موضع واحد بالضبط، وتوجيهات packing تحيط بالتعريفات ثم تُستعاد بعدها، ودالة وقت تشغيل تبلغ عن layout المحلول في صورة شيء يستطيع اختبار تأكيده. ولا تساوي التعليقات التي تدعي مطابقة struct لرأسها شيئًا؛ أما SizeOf وإزاحة حقل مطبوعة وقت الإقلاع فتساوي الكثير. وتأتي backend الخاصة بـPKCS#11 وbackend الخاصة بـCNG وبقية مكدس التوقيع ضمن PDFium Component لـDelphi وC++Builder، حيث جرى تكييف plumbing الخاصة بـABI أصلًا حتى يبقى كودك في جانب token من المشكلة