مقال تقني

جلب AIA وCRL عبر OpenSSL لتواقيع PDF في Delphi

يستطيع PDFium VCL الآن إكمال سلسلة توقيع PDF وفحص الإبطال عبر الشبكة على خلفية OpenSSL الخاصة به: حين يُفعَّل OnlineRetrieval يركّب ConfigureSslCmsVerifier مدققًا ينزّل شهادات الوساطة الناقصة من روابط AIA caIssuers وقوائم CRL من نقاط توزيع CRL، داخل ميزانية ثابتة من الوقت والطلبات والبايتات لكل استدعاء تحقق. الشهادات المنزلة مادة سلسلة فقط، والثقة تظل مصدرها حصريًا مخزن النظام والمراسي التي تضبطها

تظهر الفجوة التي يسدها أول مرة تتحقق فيها من ملفات PDF واقعية على خادم Linux. نصيب كبير من الموقّعين يضمّن في CMS شهادته الطرفية وحدها، فيعجز OpenSSL عن بلوغ جذر، وتعود TrustStatus غير صالحة، ولا يجري فحص الإبطال أصلًا لأن السلسلة لم تصبح موثوقة قط. قبل v3.121.0 كانت خلفية OpenSSL المشروحة في التحقق من تواقيع PDF بـ OpenSSL في PDFium VCL دون اتصال بصرامة، وكان OnlineRetrieval بلا أي أثر عليها. وشيء واحد يستحق القول منذ البداية: محرك PDFium نفسه لا يجري أي تحقق CMS إطلاقًا، فكل قاعدة أدناه تسكن طبقة PAdES في المكوّن ورابطته مع OpenSSL، حيث يمكنك قراءتها

بأي ترتيب تتحقق خلفية OpenSSL وتجلب وتفحص؟

السلامة أولًا، ثم الثقة، ثم الإبطال، ولا تُلمس الشبكة إلا بين الخطوات التي تحتاجها. يفحص VerifyCmsWithSsl توقيع CMS والسمات الموقعة (RFC 5652) مع تعطيل تقييم السلسلة، فإن فشل ذلك عاد فورًا قبل أن توجد جلسة جلب أصلًا، فمستند بايتاته معطوبة لا يطلق أي طلب صادر. وفقط إذا فشلت السلسلة بعدها وكان OnlineRetrieval مفعلاً يتبع روابط AIA ويتحقق ثانيةً. ونقاط توزيع CRL لا تُجلب إلا بعد أن تصبح السلسلة موثوقة، لأن CRL معلقة على مسار غير موثوق لا تثبت شيئًا. وتبقى الأحكام الثلاثة منفصلة طوال الوقت: توقيع صالح بسلسلة ناقصة ما يزال يُبلَّغ توقيعًا صالحًا

ترتيب VerifyCmsWithSsl في خلفية OpenSSL لمكوّن PDFium: يجري فحص توقيع CMS مع تعطيل تقييم السلسلة فلا تلمس البايتات المعطوبة الشبكة أبدًا، ويتبع RetrieveIntermediates روابط AIA caIssuers بعد فشل سلسلة مع تفعيل OnlineRetrieval فقط، ويجلب RetrieveCrls قوائم CRL من نقاط التوزيع إلى مخزن منفصل بعد أن تصبح السلسلة موثوقة
السلامة ثم الثقة ثم الإبطال: لا تُلمس الشبكة إلا بين الخطوات التي تحتاجها، وقائمة CRL معلقة على مسار غير موثوق لا تثبت شيئًا
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER؛ مصدر الثقة الإضافي الوحيد
  ConfigureSslCmsVerifier;

  Probe := TPdfCmsVerifyOptions.Default;
  Probe.OnlineRetrieval := True;
  Probe.CheckRevocation := True;
  Diags := SslVerifyOptionsDiagnostics(Probe);
  if psvdOnlineRetrievalIgnored in Diags then
    Log('no HTTP transport or CMS_add1_cert: validation stays offline');

  Trust := TPadesTrustValidationOptions.Default;  // الافتراضي ptnpOffline
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // لكل استدعاء تحقق

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'signed-contract.pdf';
    Pdf.Active := True;
    Verdict := Pdf.ValidatePadesTrust(Trust);
    for I := 0 to High(Verdict.Signatures) do
      Log(Format('#%d trust=%d revocation=%d', [I,
        Ord(Verdict.Signatures[I].CertificateTrustStatus),
        Ord(Verdict.Signatures[I].RevocationStatus)]));
  finally
    Pdf.Free;
  end;
end;

لماذا لا تُضاف الشهادات المنزلة إلى مخزن الثقة قط؟

لأن روابط URL تأتي من الشهادة الجري التحقق منها، وقد اختارها الموقّع نفسه. مدخل authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) تلميحٌ عن مكان وجود المُصدِر، لا أكثر. ولو دخل ما يجيب عن ذلك الرابط إلى مخزن مراسي الثقة لاستطاع أي شخص التوقيع بمفتاح صنعه بنفسه، وتوجيه AIA إلى خادمه، وتلقي حكم أخضر. لذلك يمرر RetrieveIntermediates كل شهادة محللة إلى CMS_add1_cert، التي تضعها في مجموعة غير الموثوقة الخاصة بهيكل CMS هذا وحده، وما يزال على OpenSSL أن يبني منها مسارًا إلى مرساة ضبطتها أو يحملها مخزن النظام. وهناك سبب أهدأ أيضًا: وسيط الشهادات في CMS_verify ليس بديلًا جاهزًا عن الشهادات المضمّنة في CMS، فالإضافة إلى CMS نفسه هو المسار الموثوق

وحلقة الجلب ضيقة عمدًا. تجري RetrieveIntermediates 4 جولات على الأكثر، تجمع كل منها روابط caIssuers من كل شهادة موجودة الآن في CMS، وتتوقف بمجرد أن تمر جولة بلا إضافة أو تنفد ميزانية الوقت. يجب أن يفك الرد عبر d2i_X509 شهادة DER واحدة تستهلك الجسم كله؛ البايتات الزائدة تُرفض، وحزمة PKCS#7 بالشهادات فقط قادمة من رابط .p7c تُتخطى بدل فك حزمها. وطريقة الوصول OCSP في امتداد AIA نفسه تُتجاهل، لأن هذه الخلفية لا تتكلم OCSP. وعلى جانب الإبطال تقرأ RetrieveCrls روابط URI للـ fullName في كل DistributionPoint (RFC 5280 §4.2.1.13) فقط، من شهادات CMS والمراسي المضبوطة، وتذهب قوائم CRL المنزلة إلى X509_STORE ثانٍ مستقل بفحص CRL كامل السلسلة، فتغيّر قائمة CRL غائبة أو متقادمة RevocationStatus دون أن تلمس TrustStatus قط

// مكثف من VerifyCmsWithSsl (FPdfCryptoSsl.pas)؛ حُذف إعداد BIO.
// كل استدعاء _CMS_verify ينال BIO محتوى جديدًا
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // توقيع معطوب: لا شبكة إطلاقًا
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
  FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);

Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
   (FetchSession <> nil) then
begin
  RetrieveIntermediates(Cms, FetchSession);  // CMS_add1_cert، غير موثوقة فقط
  // تحقق من السلسلة ثانيةً مقابل مخزن المراسي نفسه
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// مخزن ثانٍ منفصل: قوائم CRL المضبوطة زائد المنزلة
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

كم تكلف استدعاء تحقق واحد في أقصى الحالات؟

سقف ثابت تفرضه جلسة TPdfCryptoFetchSession واحدة تتشاركها خطوتا AIA وCRL لاستدعاء تحقق واحد. الحدود ثوابت في FPdfCryptoHttp، لا اقتراحات:

  • الوقت: UrlRetrievalTimeoutMs، والافتراضي فيه 15000 في كل من TPdfCmsVerifyOptions.Default وTPadesTrustValidationOptions.Default؛ الجلسة المنشأة بصفر ترتد إلى 30000، والساعة تبدأ متى اجتاز التوقيع الفحص، وتغطي كل طلب لاحق
  • الطلبات: 8 على الأكثر لكل جلسة، تُحسب قبل محاولة النقل، فالمضيف الميت يستهلك خانة على أي حال
  • البايتات: 1 MiB لكل رد و4 MiB إجمالًا، مع رفض روابط URL الأطول من 2048 محرفًا قبل أي اتصال
الأسقف الصارمة لجلسة TPdfCryptoFetchSession واحدة تتشاركها خطوتا AIA وCRL لاستدعاء تحقق في مكوّن PDFium: افتراضي UrlRetrievalTimeoutMs يساوي 15000 ميلي ثانية مع ارتداد الصفر إلى 30000، و8 طلبات على الأكثر لكل جلسة، و1 MiB لكل رد و4 MiB إجمالًا مع احتساب الردود الفاشلة أيضًا، ورفض روابط URL فوق 2048 محرفًا
الحدود ثوابت لا اقتراحات: بايتات الرد الفاشل تستنزف الميزانية كذلك، ولأن كل توقيع وكل طابع زمني يتحقق على حدة ينمو أسوأ الحالات مع عدد التواقيع

المحاسبة أصرم مما تبدو. بايتات الرد الفاشل تُحسب من الإجمالي كذلك، فخادم يجيب 404 بصفحة ضخمة لا يستنزف الميزانية مجانًا. القراءة التي تعبر حد الرد الواحد تُجهض التنزيل بدل أن تمرر جسمًا مبتورًا إلى محلل ASN.1، والرد HTTP 200 بجسم فارغ يُرفض رفضًا قاطعًا، لأن مسار AIA كان سيفهرس Data[0] لمصفوفة فارغة لولا ذلك. لا تمر إلا روابط http:// وhttps:// الصرفة، بلا إعادات توجيه ولا كوكيز ولا بيانات اعتماد ولا اكتشاف وسيط تلقائي، بينما يحتفظ HTTPS بفحوص شهادته واسم مضيفه الاعتيادية. وإزالة تكرار روابط URL محصورة في استدعاء واحد عمدًا: على التحقق التالي أن يرى CRL منشورة حديثًا. والميزانية لكل استدعاء لا لكل مستند، ويفحص ValidatePadesTrust كل توقيع وكل رمز طابع زمني على حدة، فينمو أسوأ الحالات مع عدد التواقيع

لماذا يمكن لطلب WinHTTP انتهت مهلته أن يكتب في ذاكرتك؟

لأن العودة عند انتهاء المهلة لا تُلغي ردود النداء الجارية أصلًا. يقود النقل في Windows WinHTTP بشكل غير متزامن وينتظر حدثًا بالوقت المتبقي للجلسة، وحين يستسلم ذلك الانتظار ما يزال بإمكان الطلب أن يكمل قراءة ويُطلق إشارة بعدها. وجّه القراءة غير المتزامنة إلى مخزن على المكدس وسيكتب ذلك الإكمال المتأخر في إطار صار ملكًا لدالة لا علاقة لها بالأمر. والعلاج ملكية لا توقيت: الحدث ومخزن القراءة بحجم 16 KB يسكنا سجلًا على الكومة بمرجعين، أحملهما المستدعي والآخر لا يحرره إلا رد نداء HANDLE_CLOSING الأخير، فيحرر أي جانب ينتهي أخيرًا الذاكرة

لماذا يمكن لطلب WinHTTP انتهت مهلته أن يكتب في الذاكرة: تُبقي العودة عند المهلة ردودَ النداء جارية، فيوجه مكوّن PDFium القراءة غير المتزامنة إلى سجل THttpState محجوز على الكومة مخزنه بحجم 16 KB ومرجعيه، أحملهما المستدعي ويحررهما رد نداء HANDLE_CLOSING الأخير، فلا تُحرر إلا حين ينتهي الجانب الأخير
قد يكمل الإكمال المتأخر قراءته بعد أن استسلم انتظارك؛ ملكية الكومة بمرجعين تعني أن تلك الكتابة تهبط في ذاكرة ما تزال حية
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // المستدعي + رد نداء HANDLE_CLOSING الأخير
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // تهبط القراءات غير المتزامنة هنا، لا على المكدس قط
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// في رد نداء الحالة: HANDLE_CLOSING هو آخر إشعار يرسله WinHTTP
// للطلب، فهو يُسقط المرجع الثاني
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

ما الذي يجب أن توفره libcurl على FPC Unix

محلل أسماء غير متزامن وبناء آمن للخيوط، وإلا بقي الجلب عبر الشبكة مطفأ. على FPC Unix يمر النقل عبر libcurl، وهي التبعية نفسها الكامنة وراء خلفية طوابع libcurl الزمنية للأهداف غير Windows، وترفض الرابطة أي مكتبة ينقص قناع ميزاتها CURL_VERSION_ASYNCHDNS أو CURL_VERSION_THREADSAFE. والسبب أن CURLOPT_NOSIGNAL، التي يجب على مكتبة تسكن عملية غيرها أن تضبطها، مقترنة بمحلل أسماء متزامن تعني أن بحث DNS يمكن أن يعيش ببساطة أطول من المهلة. والفخ الثاني الإغلاق: لا ينتظر curl_global_cleanup خيوط DNS غير المتزامنة، فبمجرد تهيئة libcurl يبقى الوحدة مضغوطًا في الذاكرة حتى تخرج العملية بدل أن يركض خيط خلفي في كود أُفرغ. وإذا فشل أي من الشرطين صارت قيمة SslCapabilities.OnlineRetrieval تساوي False ويبلّغ SslVerifyOptionsDiagnostics عن psvdOnlineRetrievalIgnored بدل التظاهر بأن الشبكة استُشيرت

ماذا تضمن النتيجة وماذا لا تضمن

قيمة RevocationStatus الصالحة من هذه الخلفية تعني أن قوائم CRL حالية تغطي السلسلة كلها وُجدت أو ضُبطت أو نُزلت، وأن ولا واحدة منها سردت شهادة منها؛ لا أكثر. لا OCSP هنا، فجهة تُصدر معلومات الإبطال عبر OCSP وحدها تترك النتيجة غير مدعومة، وفشل شبكة يبدو مطابقًا تمامًا لجهة لا تنشر شيئًا. وانتبه أن psvdNoCrlsConfigured يصف قوائم CRL التي ضبطتها أنت فقط، فمع الجلب عبر الشبكة هو تلميح لا تنبؤ بفشل. وحين يجب أن يكون مسار التدقيق قابلًا لإعادة الإنتاج دون وصول شبكة، اترك NetworkPolicy عند افتراضه ptnpOffline: لا تُنشأ جلسة جلب ولا تفتح الخلفية اتصالًا قط، وهو ما يطابق عقد العمل دون اتصال على جانب CryptoAPI المشروح في فحوص إبطال تواقيع PDF دون اتصال على Windows

كود الجلب والميزانيات وروابط النقل تُشحن مصدرًا مع مكوّن PDFium لـ Delphi، فيمكنك التأكد بالضبط أي روابط URL قد تتصل بها عملية تحقق وكم قد تنزل قبل أن تفعّل ptnpOnline على خادم يعالج مستندات غير موثوقة