مقال تقني

فحوص إبطال تواقيع PDF دون اتصال على Windows في دلفي

يفحص PDFium VCL إبطال تواقيع PDF دون اتصال على Windows بإضافة CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY إلى تمريرة الإبطال في CertGetCertificateChain، لأن علم المخزن فقط المستخدم في بناء السلسلة لا يغطي جلب CRL أو OCSP إطلاقًا. ومنذ v3.119.1 يبقى استدعاء ValidatePadesTrust دون اتصال بعيدًا عن الشبكة، والنتيجة النظيفة تشترط دليل إبطال فعليًا لكل شهادة. وبقية هذا المقال عن سبب احتياج نصفي تلك الجملة إلى الإصلاح

الترتيب الذي يفضح المشكلة عادي. خدمة تحقق تعمل على مضيف Windows مقفل، وTPadesTrustValidationOptions.NetworkPolicy تساوي ptnpOffline‏ (وهي الافتراضية أيضًا)، والمشغل يتوقع أن يأتي كل جواب من مخزن الشهادات المحلي. ثم ينتبه أحدهم إلى طلبات صادرة إلى نقطة توزيع CA في سجل الجدار الناري، أو عمل دفعي يتوقف طوال UrlRetrievalTimeoutMs البالغة 15000 ميلي ثانية عند كل توقيع. لم يطلب شيء في الكود الشبكة. ذهب إليها Windows على أي حال

لماذا لا يزال بناء سلسلة دون اتصال يجلب CRLs على Windows؟

لأن CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL لا يقيد إلا جلب عناوين URL الذي يفعله بناء السلسلة: جلبات المُصدِر عبر AIA، وتحديثات الجذر وCTL. ووثائق Microsoft لـ CertGetCertificateChain تقول صراحة إن العلم لا ينطبق على فحص الإبطال. وللإبطال مفتاحه الخاص، CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY‏ ($80000000)، وبدونه يحق لمزودي الإبطال تنزيل CRL أو إرسال طلب OCSP وإن بدا الاستدعاء المحيط دون اتصال. يجمع PDFium VCL الآن ذلك العلم OR مع تمريرة الإبطال كلما كانت OnlineRetrieval تساوي False، فوق أعلام السلسلة وCERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT وCERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. ولهذا أهمية تتجاوز زمن الاستجابة: طلب OCSP يخبر المستجيب أي شهادة تنظر فيها، وهو بالضبط ما يفترض بمحقق معزول عن الشبكة أن يتجنبه

مفتاحان مستقلان يحرسان التحقق من تواقيع PDF دون اتصال على Windows: يقيد CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL بناء السلسلة فقط كجلبات المصد عبر AIA والجذور، بينما يحتاج الإبطال إلى CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY، لأنه بدونه ما تزال المزودات تنزل CRLs وترسل طلبات OCSP تكشف أي شهادة تجري التحقق منها
يجمع PDFium VCL علم الإبطال للمخزن فقط مع تمريرة الإبطال كلما كانت OnlineRetrieval تساوي False، فيبقى تحقق الثقة ptnpOffline بعيدًا عن الشبكة في التمريرتين
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline، 15000 ميلي ثانية
  Options.CheckRevocation := True;                 // False افتراضيًا
  Options.CheckTimeStamps := True;
  // دون اتصال يعني الآن دون اتصال للإبطال أيضًا: استجابات CRL وOCSP
  // مخزنة فقط، ولا يرفع checkpoint من نوع pcvstOnlineRetrieval
  Report := Pdf.ValidatePadesTrust(Options);
end;

بناءا سلسلة، وحقلا خطأ

يبني الخلفية Windows السلسلة مرتين، ولكل بناء خانة خطأ خاصة به الآن. يجري أول استدعاء CertGetCertificateChain بلا أعلام إبطال ويغذي CertVerifyCertificateChainPolicy بالسياسة الأساسية، فينتج TrustStatus وTrustError. والاستدعاء الثاني يضيف أعلام الإبطال. وقبل v3.119.1 كان فشل ذلك الاستدعاء الثاني يكتب GetLastError في TrustError، فسلسلة تحققت للتو من موثوقيتها قد تعود تبدو غير موثوقة لأن مزود إبطال تعثر. يقرأ الإصلاح GetLastError فورًا ويخزنه في TPdfCmsVerifyResult.RevocationError، تاركًا حكم التمريرة الأولى شأنه. وعودة True من الاستدعاء الثاني لا تعامل نجاحًا أيضًا؛ إنها تعني فقط أن Windows أعاد سياق سلسلة يستحق الفحص

ماذا يثبت قناع خطأ ثقة صفري فعلًا؟

بذاته، لا شيء. ‏TrustStatus.dwErrorStatus مجمعة تساوي صفرًا بعد تمريرة الإبطال تعني أنه لم يرفع بت خطأ، وسلسلة لا يحمل أي عنصر فيها معلومات إبطال إطلاقًا تستطيع أن تعطي ذلك بالضبط. كان الكود السابق يربط «لا بت مبطل، ولا بت مجهول، ولا بت دون اتصال» بالصلاحية مباشرة، وهي الطريقة الكلاسيكية التي يبلغ بها محقق عن شهادة لم يفحصها بوصفها نظيفة. وتمشي روتينية ReadWinRevocationEvidence الجديدة على كل سلسلة بسيطة وكل عنصر، وترفض البنى التي يكون cbSize لديها أصغر من أن يقرأ بأمان، ولا تبلغ نجاحًا إلا حين يوجد عنصر واحد غير جذري على الأقل ويحمل كل عنصر من ذلك النوع ‏CERT_REVOCATION_INFO يكون dwRevocationResult لديه صفرًا

جولة الدليل التي تجريها ReadWinRevocationEvidence على كل عنصر سلسلة في Windows في دلفي: يجب أن يحضر pRevocationInfo، وأن يكون cbSize كبيرًا بما يكفي للقراءة، وأن يساوي dwRevocationResult صفرًا، ولا يستبعد العنصر الأخير إلا إذا وسم ذاتي التوقيع أو موثوقًا من CA، فلم يعد قناع خطأ ثقة صفري يخفي شهادة لم تفحص
الحكم النظيف يشترط عنصرًا واحدًا غير جذري على الأقل وجوابًا من كل عنصر مطلوب، مع بقاء RevocationError على DWORD المزود الخام مثل CRYPT_E_REVOKED
// مختصرة من جولة الدليل: لا يحسب عنصر إلا حين
// يجيب مزود إبطال فعليًا عنه
for J := 0 to ElementCount - 1 do
begin
  Element := Elements[J];
  ExcludedRoot := (J = ElementCount - 1) and
    ((Element^.TrustStatus.dwInfoStatus and
      (CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
  InfoPresent := (Element^.pRevocationInfo <> nil) and
    (Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
  if not ExcludedRoot then
  begin
    Inc(RequiredCount);
    if not InfoPresent or
      (Element^.pRevocationInfo^.dwRevocationResult <> 0) then
      Complete := False;
  end;
end;
Complete := Complete and (RequiredCount > 0); // سلسلة جذرية فقط لا تثبت شيئًا

تبقى نتيجة المزود خامًا. تحفظ RevocationError الـ DWORD‏ dwRevocationResult كما أعاده المزود تمامًا، مفضلة خطأ العنصر المبطَل حين يوجد‏ (CRYPT_E_REVOKED تساوي $80092010)، ولا يلبس قناع الثقة أبدًا زي كود خطأ أصلي. والربط إلى TPdfCmsRevocationReason خشن عمدًا: ‏pcrrCertificateRevoked مع pcvsInvalid للإبطال الصريح، وpcrrChainUntrusted حين تفشل السلسلة لأسباب لا علاقة لها بالإبطال، وpcrrUnknown لكل ما سوى. ربما جرب Windows الـ OCSP لا CRL، فلا يترجم ناتج دون اتصال أو مجهول إلى pcrrCrlExpired. وتستطيع خلفية التحقق OpenSSL CMS أن ترسم تلك التمييزات الخاصة بـ CRL لأنها لا تقيّم إلا CRLs تسلمها إليها، بينما تترك خلفية macOS SecTrust الحقول على pcrrNone وصفر، بمعنى «لا تشخيص مفصل»، لا «نجح»

أين يتوقف استبعاد الجذر

يتخطى CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT المرساة بشكل مشروع، إذ لا أحد ينشر CRL يبطل جذرًا ضد نفسه. والفخ في تحديد أي عنصر هو الجذر. يستبعد PDFium VCL العنصر الأخير من سلسلة بسيطة فقط حين يوسمه dwInfoStatus ذاتي التوقيع‏ ($00000008) أو موثوقًا من CA صراحةً ($00004000). مضيف دون اتصال كثيرًا ما يعجز عن جلب مصدر غائب، فتنتهي السلسلة عند وسيط؛ ومعاملة العنصر الأخير لتلك السلسلة الجزئية بوصفه جذرًا كانت ستسقط بصمت الشهادة الوحيدة الأكثر احتمالًا أن يكون حال إبطالها غائبًا عن المخزن. يبقى ذلك العنصر في المجموعة المطلوبة، وبلا جواب من مزود، وتبقى النتيجة pcvsIndeterminate

كيف تبقى نتائج إبطال التوقيع والطابع الزمني منفصلة؟

حقولًا منفصلة لا يتجاوز أحدها الآخر أبدًا. يتحقق مدقق PAdES من CMS المنفصل لتوقيع المستند وCMS الملحق لرمز الطابع الزمني RFC 3161 في استدعاءين مستقلين، ومنح v3.119.0 كلًا منهما تشخيصاته الخاصة على TPadesSignatureValidation: ‏RevocationReason وNativeRevocationError للموقع، وTimeStampRevocationReason وNativeTimeStampRevocationError للـ TSA. فلا تستطيع شهادة TSA مبطلة أن تتقمص موقعًا مبطلًا، ولا يمحو فشل طابع زمني نتيجة سلامة أثبتت سلفًا. وحين تساوي CheckRevocation قيمة False، أو لم يبلغ التحقق تلك المرحلة قط، تبقى الحقول pcrrNone و0، فاقرأهما دائمًا بجوار RevocationStatus وTimeStampRevocationStatus

يبقى إبطال التوقيع والطابع الزمني منفصلين في PDFium VCL: يملأ CMS المنفصل لتوقيع المستند RevocationReason وNativeRevocationError، ويملأ CMS الملحق لرمز RFC 3161 ‏TimeStampRevocationReason وNativeTimeStampRevocationError، وتترك المراحل غير المفحوصة pcrrNone وصفرًا بجوار حقلي حالتهما
فلا تستطيع شهادة TSA مبطلة أن تتقمص موقعًا مبطلًا، ولا يمحو فشل طابع زمني قط نتيجة سلامة أثبتها التحقق من التوقيع سلفًا
for I := 0 to High(Report.Signatures) do
begin
  S := Report.Signatures[I];
  case S.RevocationStatus of
    pcsInvalid:
      Log(Format('sig %d: signer revoked, provider 0x%.8x',
        [I, S.NativeRevocationError]));
    pcsIndeterminate:
      Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
        [I, Ord(S.RevocationReason), S.NativeRevocationError]));
    pcsNotChecked:
      Log(Format('sig %d: revocation not checked', [I]));
  end;
  if S.TimeStampRevocationStatus = pcsIndeterminate then
    Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
      [I, S.NativeTimeStampRevocationError]));
end;

يتبع تقرير الدليل القاعدة نفسها. يلحق تصدير CSV‏ revocationReason وnativeRevocationError وأعمدة الطابع الزمني حتى nativeTimeStampRevocationError في نهاية ترتيب الأعمدة القائم، فتبقى المحللات الأقدم تعمل، ويضيف تصدير JSON حقولًا مقابلة دون تغيير معنى القديمة. وإن ظل التحقق دون اتصال يعود غير محسم، فالإصلاح الدائم في المنبع: اجمع مادة التحقق وقت التوقيع، كما يشرح تواقيع PDF طويلة الأمد بطوابع RFC 3161 الزمنية وDSS، لا أن تأمل أن لدى جهاز التحقق مخزنًا دافئًا

ما يثبته مصفوفة الاختبارات وما لا تثبته

اجتازت مصفوفة التحقق على Windows سيناريوهات chain-API محكومة عددها 30 واختبار دخان CMS حقيقيًا واحدًا دون اتصال على كل وجهة من Win32 وWin64 في Delphi وFPC. يتحقق الاختبار الحقيقي من توقيع صحيح تحت CA خاص غير موثوق، بينما تأتي النتائج النظيفة والمبطلة صراحةً من استجابات ‏CertGetCertificateChain مستبدلة لا من مراسي ثقة مثبتة أو جلب حي. تلك حدود صريحة تستحق الذكر: معالجة الأعلام وعزل الأخطاء وجولة الدليل مثبتة، لكن ما يحويه مخزن إبطال جهاز معين في يوم معين لا يزال شأن Windows، والمخزن الفارغ يعطي الآن «مجهولًا» بشكل صحيح بدل طلب شبكة أو «صالح» كاذب

معالجة الإبطال دون اتصال والتشخيصات لكل حقل وتصديرات الدليل جزء من API التحقق من تواقيع PDF في PDFium VCL لدلفي وC++Builder، بجوار خلفيتي OpenSSL وmacOS للنشر عبر المنصات