مقال تقني

شهادات اختبار موقَّعة ذاتيًا في Delphi عبر CryptoAPI

تبني دالة PLCreateSelfSignedCertificate في PDFlibPas شهادة RSA/SHA-256 موقَّعة ذاتيًا وتُصدرها، بما في ذلك المفتاح الخاص، مباشرة إلى ملف PFX محمي بكلمة مرور، مستخدمة لا شيء سوى Win32 CryptoAPI المثبتة بالفعل على كل جهاز ويندوز. لا أداة خارجية، لا سلطة إصدار شهادات، لا خطوة يدوية بـmakecert أو OpenSSL: استدعاء دالة واحد، شهادة واحدة كافية لتشغيل اختبار توقيع

السيناريو الذي يجعل هذه الدالة تستحق الوجود هو دائمًا تقريبًا خط أنابيب تكامل مستمر (CI). يحتاج اختبار دخان للتوقيع ملف PFX حقيقيًا بمفتاح خاص حقيقي خلفه، وحفظ واحد في المستودع مشكلة أمنية بحد ذاتها، بما أن مفتاحًا خاصًا مُرسَلًا هو مفتاح خاص مسرَّب من اللحظة التي يصل فيها ذلك الـcommit. تشغيل makecert.exe أو استدعاء OpenSSL من سكربت بناء يعمل أيضًا، لكن يعتمد خط الأنابيب حينها على أداة يجب تثبيتها، وإيجادها في PATH، وإبقاؤها متسقة الإصدار عبر كل وكيل بناء. توليد الشهادة داخل العملية نفسها التي تشغّل الاختبار، باستدعاءات Win32 CryptoAPI نفسها التي يشحنها ويندوز بالفعل، يزيل ذلك الاعتماد كليًا

ما الذي تنتجه PLCreateSelfSignedCertificate فعليًا؟

تنتج PLCreateSelfSignedCertificate ملف PFX محميًا بكلمة مرور يحمل شهادة RSA موقَّعة ذاتيًا ومفتاحها الخاص، موقَّعة بـsha256RSA، مدفوعة بخمسة معاملات: SubjectName، وPFXFileName، وPFXPassword، وValidDays، وKeyBits، وتُعيد علم نجاح Boolean بسيطًا. يقبل SubjectName سلسلة X.500 كاملة مثل 'CN=Alice, O=Example'، ويُسبَق اسم مجرد بلا علامة = فيه تلقائيًا بـCN=. تعود ValidDays أقل من 1 إلى 365 افتراضيًا، وتعود KeyBits خارج نطاق 1024 إلى 16384 إلى 2048 افتراضيًا. يشحن PDFlibPas هذه الدالة منذ v3.224.0، يمكن الوصول إليها ليس فقط من وحدة Delphi بل أيضًا عبر أسطح DLL وActiveX، وتعليق التوثيق الخاص بها صريح حول أين تتوقف عن كونها مفيدة: كل عارض رئيسي يعلّم شهادة موقَّعة ذاتيًا كغير موثوقة ما لم يُثبّتها أحد صراحة، لذا عامل ما تنتجه كشهادة لممارسة مسار شيفرة، لا توقيعًا يُطلَب من أي شخص خارج فريقك الاعتماد عليه

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDFlibPas CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

لماذا ترمّز CryptGenKey طول المفتاح في معامل الأعلام؟

تحزم CryptGenKey إعدادين غير مرتبطين في معامل dwFlags واحد. تحمل الكلمة المنخفضة أعلام سلوك، من ضمنها CRYPT_EXPORTABLE، بينما تحمل الكلمة العليا، لمفتاح تبادل مفاتيح RSA، طول المفتاح المطلوب بالبت. تمرير 2048 كما لو كانت مجرد علم آخر يضعها في الكلمة المنخفضة بدلًا من ذلك، حيث لا تطابق أي علم سلوك يعرّفه CryptoAPI، بحيث يولّد الاستدعاء مفتاحًا بأيًّا كان الطول الافتراضي الذي يعود إليه المزوّد بدلًا من الطول الذي ظن المستدعي أنه طلبه. الحصول على مفتاح RSA فعلي بـ2048 بت يعني إزاحة الرقم إلى الكلمة العليا أولًا

// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

ماذا يحدث إذا نسيت CRYPT_EXPORTABLE؟

أسقط CRYPT_EXPORTABLE من قيمة الأعلام نفسها ولا تزال CryptGenKey تنجح، لكنها تُعلِّم المفتاح الخاص المُولَّد غير قابل للتصدير على مستوى CSP. يستمر كل شيء لاحق في الإبلاغ عن النجاح أيضًا: تُعيد CertCreateSelfSignCertificate سياق شهادة صالحًا، وتنجح PFXExportCertStoreEx، حتى عند استدعائها بـEXPORT_PRIVATE_KEYS، رغم ذلك وتكتب ملف PFX يُفتح، ويُحلَّل، ويبدو عاديًا تمامًا. ما لا يحتويه هو المفتاح الخاص، لأن CSP رفض السماح له بالخروج من حاوية المفتاح، ولا تعامل PFXExportCertStoreEx أبدًا ذلك الرفض كسبب لإفشال التصدير بأكمله

لا يظهر الفشل إلا لاحقًا، وفي مكان آخر كليًا: يفتح استدعاء توقيع ذلك الملف PFX، ويجد شهادة بلا مفتاح خاص مرفَق، ويبلّغ بالضبط عن الخطأ الذي كنت لتحصل عليه من ملف PFX تالف أو خاطئ، لا من علم مفقود بثلاث طبقات في المنبع. أي شخص يصحّح الأخطاء من جانب التوقيع وحده يمكن أن يستهلك بعد ظهر كامل على الملف الخاطئ قبل أن يدرك أن الخلل الفعلي بت واحد مفقود وقت توليد المفتاح، في استدعاء دالة مختلف كليًا، ربما في سكربت بناء مختلف كليًا

لماذا يجب أن يتطابق ProvType بين CryptAcquireContextW والشهادة؟

يجب أن يتطابق ProvType لأن CertCreateSelfSignCertificate تحل المفتاح الخاص للشهادة الجديدة عبر سجل CRYPT_KEY_PROV_INFO، ويتعين على حقل واحد في ذلك السجل، ProvType، تسمية قيمة نوع CSP نفسها بالضبط المُمرَّرة إلى CryptAcquireContextW عندما فُتحت حاوية المفتاح، PROV_RSA_AES، رقميًا 24، في تنفيذ PDFlibPas. اضبط ProvType على صفر، أو على أي ثابت مزوّد آخر غير الذي تنتمي إليه الحاوية فعليًا، ولا تزال الشهادة قادرة على الإنشاء، لكن رابطها المسجَّل مرة أخرى إلى المفتاح الخاص لم يعد يُحل إلى الحاوية التي تحمله، وهو ما يظهر لاحقًا كفشل توقيع أو تصدير لا علاقة له بمحتوى الشهادة التشفيري الفعلي

// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES;   // same constant, both call sites

تجميع الأمر معًا: من حاوية GUID إلى PFX محمي بكلمة مرور

يتبع سلسلة الاستدعاء داخل PLCreateSelfSignedCertificate خطًا مستقيمًا واحدًا، بفتح حاوية مفتاح جديدة مسمّاة بحسب GUID مُولَّد حديثًا بحيث لا تتصادم تشغيلات CI المتزامنة أبدًا على أسماء الحاويات، وتوليد زوج مفتاح RSA بداخلها بالعلمين المغطاة أعلاه، وترميز SubjectName إلى كتلة اسم X.500 عبر CertStrToNameW، واستدعاء CertCreateSelfSignCertificate بنافذة صلاحية محسوبة من ValidDays ومُسلَّمة كبنية بشكل SYSTEMTIME عادية. يذهب سياق الشهادة الناتج إلى متجر شهادات في الذاكرة مفتوح بـCertOpenStore وCERT_STORE_PROV_MEMORY، فقط لأن PFXExportCertStoreEx تحتاج متجرًا للتصدير منه، بما أن تلك الواجهة البرمجية تعمل مقابل مقبض متجر بدلًا من سياق شهادة مجرد

// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

تتبع PFXExportCertStoreEx نفسها اصطلاح Win32 ذا المرورين العادي: استدعِها مرة بمخزن مؤقت بطول صفر لمعرفة عدد البايتات التي يحتاجها PFX، وخصص ذلك القدر، ثم استدعِها مرة أخرى لملء المخزن المؤقت. بمجرد أن تكون البايتات على القرص، يحذف PDFlibPas حاوية المفتاح المؤقتة بـCRYPT_DELETEKEYSET بدلًا من تركها، لأن PFX يحمل بالفعل نسخته الخاصة من كل بايت من مادة المفتاح التي كانت الحاوية تحملها. تخطَّ ذلك التنظيف وسيترك كل استدعاء لـPLCreateSelfSignedCertificate حاوية مفتاح يتيمة مسمّاة بـGUID تجلس في ملف تعريف المستخدم المستدعي، وهذا بالضبط نوع التسريب الذي سيتراكمه وكيل CI يشغّل هذه الدالة عند كل بناء طوال أشهر قبل أن يلاحظ أحد ذلك

هل شهادة موقَّعة ذاتيًا آمنة للاستخدام في توقيع الإنتاج؟

لا: شهادة موقَّعة ذاتيًا آمنة لممارسة مسار شيفرة توقيع وغير آمنة لتوقيع يُتوقَّع من أي شخص خارج الفريق الوثوق به، لأن لا شيء يربطها مرة أخرى بجذر تثق به بالفعل برمجيات طرف معتمِد. الخطوة التالية الطبيعية لـPFX كهذا استدعاء توقيع فعلي، مشروح في بناء منصة توافق وتوقيع في Delphi عبر PDFlibPas، حيث يقود PFX مبني بهذه الطريقة نصف التوقيع من خط أنابيب يشغّل أيضًا فحصًا مسبقًا لـPDF/A وتدقيقات ByteRange. لكن التوقيع نصف ما يقف حول شهادة فقط، والنصف الآخر بالضبط حيث يُفترض أن تفشل ورقة موقَّعة ذاتيًا: تغطي توقيع PAdES والتحقق منه في Delphi عبر PDFlibPas فحوصات سلسلة الثقة التي يشغّلها مُتحقق توافق، ومُتحقق يجتاز السلسلة إلى جذر موثوق لا سبب لديه للثقة بشهادة اخترعتها هذه الدالة قبل خمس دقائق من لا شيء

PLCreateSelfSignedCertificate دالة واحدة من بين واجهات برمجة الشهادات والتوقيع في مكتبة PDFlibPas لـPDF لـDelphi وC++Builder، وهي موجودة بالضبط للفجوة الموصوفة هنا: اختبار توقيع يحتاج زوج مفتاح حقيقي خلفه ولا شيء خارجي لتوليد واحد