يقوم HotPDF بتوقيع ملف PDF مقابل شهادة موجودة بالفعل في متجر شهادات ويندوز عبر تسليم بصمة الملخص (digest) إلى ويندوز نفسه، ويُنجز ويندوز هذا الطلب عبر إحدى خلفيتين للمفتاح الخاص: CNG، التي تُعيد توقيع RSA بترتيب البايت الكبير (big-endian)، أو واجهة CryptoAPI CSP القديمة، التي تُعيده بترتيب البايت الصغير (little-endian). إذا حصل خلط بين الاثنين، ينعكس ترتيب بايتات توقيع CMS الذي يُضمّنه HotPDF وفق الخلفية التي استجابت فعليًا، فيُبلغ أي مُدقّق متوافق بأن التوقيع غير صالح رغم أن بايتات المستند لم تُمسّ إطلاقًا
تختبئ خلف هذه الجملة الواحدة مشكلتان منفصلتان، ويتعيّن على موقّع الشهادة النظامية في HotPDF حلّهما كلتيهما قبل أن يوقّع أي شيء أصلًا. عدم تطابق ترتيب البايتات صامت: استدعاء التوقيع لا يزال يُعيد True، وملف PDF لا يزال يُفتح، والفشل لا يظهر إلا حين يتفحّص عارض ما بنية CMS ويرفضها. أما المشكلة الثانية فصاخبة وخاصة بـC++Builder: نصف دزينة من دوال crypt32 ترفض الربط، لأن مكتبة الاستيراد التي يشحنها RAD Studio لا تُصدّرها. لا توجد أي من المشكلتين إن كنت توقّع دائمًا باستخدام ملف PFX فقط، وهذا سبب شيوعها لدى المطورين المنتقلين من التوقيع أحادي الاستدعاء المعتمد على PFX إلى شهادة ثبّتها قسم تقنية المعلومات مسبقًا في ملف تعريف المستخدم
اختيار شهادة من المتجر
يعرض HotPDF هذا المسار عبر HPDFSignPDFStreamWithSystemCertificate وHPDFSignPDFFileWithSystemCertificate، وكلاهما مدفوع بسجل THPDFCertificateStoreSelector: Location (إما cslCurrentUser أو cslLocalMachine)، وStoreName (وهو 'MY'، أي المتجر الشخصي، افتراضيًا)، وبصمة SHA-1 عبر Thumbprint، وعلم AllowUI. تُطبّع البصمة داخليًا، بحيث تُزال الشرطات أو الفراغات المنسوخة مباشرة من واجهة مدير الشهادات قبل إجراء المقارنة
var
Selector: THPDFCertificateStoreSelector;
Options: THPDFCMSSignOptions;
begin
Selector := THPDFCertificateStoreSelector.Default; // cslCurrentUser, store 'MY'
Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
Selector.AllowUI := False;
Options := HPDFCMSDefaultOptions(palBaseline_B_B);
if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
'invoice-signed.pdf', Selector, Options) then
raise Exception.Create('Certificate-store signing failed');
end;
القيمة AllowUI = False أهم مما تبدو عليه، لأنها تُطابق مباشرة العلم CRYPT_ACQUIRE_SILENT_FLAG، ويلتزم ويندوز بها حرفيًا: إذا كان المفتاح الخاص للشهادة المطابقة مقيمًا على بطاقة ذكية أو رمز مادي يحتاج مطالبة برمز PIN لم يخزّنها ويندوز مسبقًا في الذاكرة المؤقتة، فإن CryptAcquireCertificatePrivateKey يفشل بدلًا من إظهار نافذة حوار من عملية قد تكون خدمة (service). هذا الفشل صاخب، إذ يظهر استثناء EHPDFCMSError فورًا، لكن يسهل إساءة تفسيره على أنه "الشهادة غير موجودة" بينما السبب الحقيقي رمز مادي ينتظر PIN لن يكتبه أحد
لماذا تختلف CNG وCAPI في ترتيب البايتات؟
الخلفية التي تستجيب ليست تخمينًا: تُبلّغ CryptAcquireCertificatePrivateKey عنها مباشرة عبر معامل خرج KeySpec، وهذه القيمة الوحيدة هي ما يتفرّع عليه موقّع HotPDF. مفتاح موفّر تخزين مفاتيح CNG (Key Storage Provider) يعود بقيمة KeySpec مضبوطة على القيمة الحارسة CERT_NCRYPT_KEY_SPEC ($FFFFFFFF)؛ وأي قيمة أخرى تعني مفتاح CryptoAPI CSP تقليديًا. معظم الشهادات الشخصية الصادرة أو المستوردة على تثبيت ويندوز حديث تُحلّ إلى CNG رغم بقاء طبقة توافق قديمة (shim) لـCSP لأغراض التوافق، ولهذا يطلب HotPDF العلم CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG مع CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG قبل أن ينظر إلى القيمة العائدة
الخلفيتان لا تستدعيان دوالًا مختلفة فحسب، NCryptSignHash مقابل مفتاح CNG، وCryptSignHashA مقابل مفتاح CSP، بل تُعيدان توقيع RSA الخام بترتيب بايتات معاكس. مخرجات CNG تطابق بالفعل ما يتوقعه PKCS#1: سلسلة أوكتيتات بترتيب البايت الكبير، الأكثر أهمية أولًا، وهو بالضبط ما ينتجه تحويل I2OSP في RFC 8017 وما تحتاجه بنية SignerInfo لـCMS (RFC 5652) في حقل التوقيع بموجب ISO 32000-1 §12.8.3. أما CryptSignHash في CryptoAPI فتُعيد التوقيع بترتيب البايت الصغير، وهي خاصية موثّقة تعود جذورها إلى طريقة تمثيل CSP الكلاسيكية للأعداد الكبيرة داخليًا. تجاهل عملية الانعكاس على مسار CAPI يجعل كل بايت في التوقيع في موضع خاطئ؛ حسابات RSA الرياضية لا تزال صحيحة، لكن سلسلة الأوكتيتات التي يقرأها المُدقّق ليست تلك التي يُعرّفها PKCS#1
// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
Temp := Signature[I];
Signature[I] := Signature[High(Signature) - I];
Signature[High(Signature) - I] := Temp;
end;
ماذا عن دالة رد نداء موقّع مخصصة؟
أي شخص يتجاوز موقّع الشهادة النظامية المدمج في HotPDF يرث قاعدة ترتيب البايتات نفسها. تأخذ HPDFCMSSignPDFStreamWithExternalSigner نوعًا يُسمى THPDFCMSSignDigestCallback، وهو إغلاق (closure) من النوع reference to function(const SignedAttributesSHA256: TBytes): TBytes، للتوقيع عبر HSM أو حزمة برامج وسيطة لبطاقة ذكية أو أي شيء آخر ليس شهادة يمكن لمتجر ويندوز أن يمنحك عبرها مقبض مفتاح. مهما كانت الخلفية القابعة خلف دالة رد النداء تلك، يجب أن تصل البايتات التي تُعيدها بترتيب البايت الكبير قبل أن يطويها HotPDF داخل بنية CMS
Signer :=
function(const SignedAttributesSHA256: TBytes): TBytes
begin
if UsesCngKeyStorageProvider then
Result := SignWithMyCngKey(SignedAttributesSHA256) // already big-endian
else
Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
CertificateDER, Signer, Options);
يستحق الأمر توضيحًا صريحًا لحدٍّ هنا: مساران التوقيع المدمجان في HotPDF، CNG عبر NCryptSignHash مع حشو PKCS#1، وCAPI عبر CryptSignHashA، يستهدفان كلاهما مفاتيح RSA لتوقيع ملخص SHA-256 مكوّن من 32 بايت. لا يتفاوض أي منهما على صيغة توقيع ECDSA. أما الشهادة التي مفتاحها الخاص مبني على المنحنيات الإهليلجية (EC) فتحتاج موقّعًا تكتبه بنفسك مقابل HPDFCMSSignPDFStreamWithExternalSigner، مع ترميز توقيع ECDSA بالطريقة التي يتوقعها CMS بدلًا من افتراض سلسلة بايتات RSA ثابتة الطول، لذا لا تتوقع أن يقوم موقّع الشهادة النظامية المدمج بالمهمة الصحيحة لرمز مزوَّد بشهادة EC
لماذا يفشل C++Builder في ربط CertOpenStore؟
لأن مكتبة الاستيراد الافتراضية لـC++Builder في RAD Studio، وهي import32.lib، لا تُصدّر CertOpenStore، ولا خمسة من جاراتها: CertEnumCertificatesInStore، وCertGetCertificateContextProperty، وCertFreeCertificateContext، وCertCloseStore، وCryptAcquireCertificatePrivateKey. مشاريع Delphi لا تواجه هذا إطلاقًا، لأن dcc32/dcc64 يحلّان استيراد external 'crypt32.dll' الساكن مباشرة في جدول استيراد ملف PE. أما C++Builder فمختلف: يُصدر مترجم Delphi ملف .obj بصيغة OMF عند بناء الحزمة، ويربطه ilink32، وعند تلك النقطة يصبح إعلان external نفسه مجرد رمز غير محلول ينتظر مكتبة استيراد على سطر الأوامر. توجيه الرابط إلى مجلد psdk التابع لحزمة SDK الخاصة بويندوز، حيث تُصدّر crypt32.lib الكاملة الرموز الستة جميعها، لا يصلح المشكلة أيضًا: يربط ilink32 فقط مكتبات الاستيراد المذكورة فعليًا على سطر أوامره، أي import32.lib cp32mt.lib افتراضيًا، وإضافة مسار بحث لا تجعله يسحب أي شيء إضافي من ذلك المسار. تشغيل tdump على import32.lib يؤكد الفجوة مباشرة، صفر نتائج لـCertOpenStore، مقابل ست نتائج نظيفة في crypt32.lib الخاصة بحزمة SDK
يحل HotPDF هذه المشكلة بنفس الطريقة التي يتعامل بها بالفعل مع تعداد الشهادات في مواضع أخرى من المكتبة: بدلًا من مطالبة الرابط بهذه الرموز، يُحمّلها في وقت التشغيل. يحمل سجل داخلي باسم THPDFCryptoProcs مقبض crypt32.dll، ومقبض advapi32.dll، وأحد عشر حقلًا لمؤشرات الدوال؛ تُحمّل LoadCryptoProcs كلا المكتبتين الديناميكيتين وتحلّ كل نقطة دخول عبر GetProcAddress مرة واحدة بالضبط، في بداية HPDFSignPDFStreamWithSystemCertificate، مع إطلاق EHPDFCMSError فورًا إن كان أي شيء مفقودًا بدلًا من الفشل لاحقًا بخرق وصول (access violation) في عمق مسار التوقيع
type
TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
Crypt32Handle: HMODULE;
CertOpenStore: TCertOpenStoreFn;
begin
Crypt32Handle := LoadLibrary('crypt32.dll');
if Crypt32Handle = 0 then
raise Exception.Create('crypt32.dll could not be loaded');
@CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
// ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;
يحدث التحميل مرة واحدة لكل استدعاء بدلًا من تحميله بأسلوب كسول (lazily) داخل كل دالة مساعدة، لأن الإغلاق الذي يختار بين CNG وCAPI يلتقط جدول الدوال المُحمَّل بالقيمة ويجب أن يبقى حيًا طوال مسار التوقيع بأكمله، بما في ذلك رد النداء إلى HPDFCMSSignPDFStreamWithExternalSigner؛ ويُحرَّر كلا مقبضي المكتبات الديناميكيتين في كتلة finally الخارجية بمجرد انتهاء التوقيع أو إطلاقه استثناءً. لا شيء من هذا يمسّ الواجهة العامة: تحتفظ HPDFSignPDFStreamWithSystemCertificate، وHPDFSignPDFFileWithSystemCertificate، وTHPDFCertificateStoreSelector بتوقيعاتها ذاتها التي كانت عليها من قبل، لذا فإن تبنّي الإصلاح هو مجرد إعادة بناء (rebuild) للمستدعين الحاليين، لا تعديل في الشيفرة
ما لا يغطيه هذا
ضبط ترتيب البايتات وحل مشكلة الربط في C++Builder ينتج بنية SignerInfo لـCMS يمكن لأي مُدقّق تحليلها والتحقق منها حسابيًا؛ وهذا لا يقول شيئًا عن مدى وجوب ثقة ذلك المُدقّق بالشهادة الكامنة خلفها، إذ يُعدّ بناء سلسلة الثقة، والتحقق من الإبطال (revocation)، وسياسة الطابع الزمني اعتبارات منفصلة تُضاف طبقةً فوق الأخرى عبر خيارات CMS، وليست شيئًا تمنحك إياه صحة ترتيب البايتات مجانًا. تفصيلان تدبيريان يهمان بقدر أهمية التشفير نفسه: يجب تحرير PCCERT_CONTEXT العائد من البحث عن الشهادة عبر CertFreeCertificateContext قبل إغلاق المتجر، ومقبض مفتاح CNG أو CSP المُكتسب، حين تُبلغ الواجهة البرمجية بأن المستدعي يملكه، يجب تحريره عبر استدعاء الخلفية المطابقة نفسها، لا الأخرى أبدًا. إذا تبيّن أن نتيجة svValid التي تحصل عليها بعد كل هذا أضيق مما توقعت، فإن المقالة الخاصة بالتحقق من توقيعات PDF الرقمية تشرح بالضبط ما تعد به تلك العلامة وما لا تعد به. ولأن الشهادة تبقى في عهدة ويندوز طوال الوقت هنا، يتجنب التوقيع من متجر الشهادات سطح هجوم كاملًا: فلا يوجد ملف PKCS#12 يجب تحليله، ولا بنية ASN.1 يجب اجتيازها بنفسك، وهذه هي المشكلة التي يعالجها تحصين HotPDF لـPKCS#12 وASN.1 بدلًا من ذلك لمسار التوقيع القائم على ملف PFX
التوقيع من متجر الشهادات، وتوقيع PFX، ودوال رد نداء الموقّع الخارجي هي ثلاثة أبواب تفضي إلى خط أنابيب CMS/PKCS#7 نفسه داخل مكوّن HotPDF لـPDF الخاص بـDelphi وC++Builder، واختيار الباب المناسب يعتمد بشكل رئيسي على من يُسمح له بحمل المفتاح الخاص: عمليتك، أو ملف PFX، أو ويندوز نفسه