مقال تقني

قراءة تواقيع CMS في Delphi: حدود DER وأقواس OID

يستخرج PDF Library for Delphi‏ (PDFlibPas) شهادات توقيع PDF بتجوال DER خالص فوق CMS SignedData المخزّن في /Contents، دون أي سند إلى CryptoAPI. ومنذ v3.539.10 تحدد كل قراءة متداخلة بحدود عنصرها الأم، ويقص الحشو الصفري الذي يلي CMS عند الطول الذي يعلنه CMS، وتنثق معرفات الكائنات OID قوسها الفرعي الأول المدمج بترميز base-128. وقاعدة الحدود وإصلاح OID كلاهما استبدل كوداً كان يجيب إجابات خاطئة دون أن يرفع خطأً، أما قاعدة الحشو فتبقي القارئ الأشد صرامة من رفض تواقيع حقيقية

جهة القراءة أهم مما تبدو. أدوات التحقق طويل الأمد لا بد أن تسحب شهادة الموقّع ومصدريه من توقيع قائم قبل أن تجلب بيانات الإبطال، وتقرير التدقيق لا بد أن يقول من وقّع، وبناء Lazarus على لينكس لا يملك دوال رسائل ويندوز التي يستند إليها. والمحلل في هذا الموقع نادراً ما ينهار أمام مدخل سيئ. صيغة الفشل المؤلمة هي عدّ شهادات يحتسب بايتات لجارٍ، أو مطابقة موقّع على حقل خاطئ، أو OID يتحول بهدوء إلى OID آخر. فخط أنابيب تواقيع مبني فوق ذلك يبلّغ هراءً بثقة كاملة

قراءة شهادات الموقّع من PDF موقّع

خمس طرق في TPDFlib تغطي جهة القراءة، وكلها تأخذ InputFile, Password, FieldName: كل استدعاء يفتح الملف للقراءة فقط، ويجيب، ثم يغلقه من جديد. ‏GetSignatureEmbeddedCertificateCount و GetSignatureEmbeddedCertificateDER تعدّان مجموعة الشهادات بترتيب الترميز، و GetSignatureSignerCertificateDER تعيد الشهادة التي أنتجت SignerInfo معيناً، و GetSignatureCertificateChainLength / GetSignatureCertificateChainDER تجولان من ذلك الموقّع نحو أبعد مصدر تحمله التواقيع نفسها. الفهارس تبدأ من الصفر. واحفظ النتائج في AnsiString، وهذا سبب إعادتها للمكتبة على هذه الصورة: كتلة DER تمر عبر string أو TStrings تخوض تحويل مجموعة محارف وتعود تالفة

uses
  SysUtils, Classes, PDFlibrary;

procedure SaveDer(const FileName: string; const Der: AnsiString);
var
  Fs: TFileStream;
begin
  Fs := TFileStream.Create(FileName, fmCreate);
  try
    if Der <> '' then
      Fs.WriteBuffer(Der[1], Length(Der));
  finally
    Fs.Free;
  end;
end;

const
  Src = 'contract-signed.pdf';
  Field = 'Signature1';
var
  Pdf: TPDFlib;
  ChainLen, I: Integer;
  SignerDer, LastDer: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    WriteLn('Certificates in the CMS: ',
      Pdf.GetSignatureEmbeddedCertificateCount(Src, '', Field));
    SignerDer := Pdf.GetSignatureSignerCertificateDER(Src, '', Field, 0);
    if SignerDer = '' then
      raise Exception.Create('signer certificate missing or not matched');
    SaveDer('signer.cer', SignerDer);

    ChainLen := Pdf.GetSignatureCertificateChainLength(Src, '', Field, 0);
    for I := 0 to ChainLen - 1 do
      SaveDer(Format('chain-%d.cer', [I]),
        Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, I));
    if ChainLen > 0 then
    begin
      LastDer := Pdf.GetSignatureCertificateChainDER(Src, '', Field, 0, ChainLen - 1);
      WriteLn('Next issuer, if any: ', Pdf.GetCertificateIssuerURLs(LastDer));
    end;
  finally
    Pdf.Free;
  end;
end;

أمران في ذلك الناتج يستحقان انتباهاً. العدد 0 ليس تشخيصاً: حقل مفقود، وكلمة سر خاطئة، وكتلة ليست DER، و SignedData يتجاهل ببساطة مجموعة الشهادات الاختيارية، كلها تعود 0 أو سلسلة فارغة، فسجّل اسم الحقل إلى جانب الرقم. وسلسلة تنتهي قبل شهادة صادرة عن ذاتها ليست خطأً هي أيضاً. بانئ السلاسل لا يستخدم إلا الشهادات المضمّنة في التواقيع، فيجب جلب المصدرين الباقيين عبر العناوين التي يبلّغ عنها GetCertificateIssuerURLs

كم من /Contents يعود فعلاً لـ CMS؟

البادئة التي يعلنها SEQUENCE الخارجي وحده هي ما يعود لـ CMS، و PLTrimCMSPadding يقص كل ما بعدها. الموقّع يحجز سلسلة /Contents السداسية قبل وجود CMS أصلاً، لأن /ByteRange الموصوفة في ISO 32000-1 §12.8.1 يجب أن تثبت أولاً، فيُقاس الحجز بسخاء ويبقى الذيل غير المستخدم أصفاراً. يقرأ PLTrimCMSPadding أول TLV، ويشترط وسم $30، ويعيد البايتات حتى نهاية ذلك العنصر؛ وما لا يبدأ بـ SEQUENCE مشكّل تشكيل سليم يعود فارغاً. ذلك المستوى الأعلى هو الموضع الوحيد الذي تكون فيه البايتات اللاحقة مشروعة، والتمييز مهم للقسم التالي: قاعدة صارمة «يجب أن يستهلك العنصر المخزن كله» لرفضت كل توقيع واقعي، بينما قاعدة متساهلة تُطبق على كل عمق تسمح للحقول المتداخلة بقراءة بايتات لا تملكها

يقرأ PDFlibPas ‏PLTrimCMSPadding أول TLV من سلسلة /Contents السداسية المحجوزة، ويشترط الوسم $30 ويقص الحشو الصفري عند الطول الذي يعلنه SEQUENCE الخارجي، ويعيد نتيجة فارغة حين لا يبدأ المخزن بـ SEQUENCE مشكّل تشكيل سليم
البايتات اللاحقة مشروعة على المستوى الأعلى وحده، حيث يجب أن يبقى الحجز المخصص ثابتاً لأجل /ByteRange — والقراءات الأعمق تأخذ قاعدة حدود الأب بدلاً منها

لماذا يحتاج قارئ DER إلى إزاحة نهاية الأب؟

العنصر المتداخل صالح فقط إذا انتهى داخل أمه، والفحص ضد نهاية المخزن لا يثبت ذلك. ‏DERReadTLV منخفضة المستوى في PDFlibASN1 تحدد كل عنصر بسلسلة كاملة، وهذا هو الفحص الصائب للكائن الأقصى والفحص الخطأ لكل ما تحته. تصوّر SignerInfo تعلن issuerAndSerialNumber طولاً 40 بايت بينما اسم المصدر issuer Name داخلها يدّعي 60. كل بايت ما زال في المخزن، فيقبل القارئ المحدد بالمخزن الاسمَ، ويقرأ الرقم التسلسلي من خوارزمية الهضم التي تليه، ثم يقارن ذلك الثنائي بالشهادات المضمّنة. وقبل v3.539.10 كان تجوال CMS يقرأ بهذه الطريقة بالضبط. الإصلاح غلاف صغير يحمل موضع نهاية الأب إلى كل قراءة

يحدد PDFlibPas كل قراءة DER متداخلة بحدود عنصر الأم: اسم مصدر من 60 بايت داخل issuerAndSerialNumber من 40 بايت يقبله DERReadTLV القديم المحدد بالمخزن، الذي يقرأ بعدها الرقم التسلسلي من digestAlgorithm، بينما يرفض ReadTLVWithin أي عنصر ينتهي بعد ParentEnd
داخل المخزن أمان الذاكرة، وداخل الأب الصواب — يمرر PDFlibPas إزاحة نهاية الأب عبر كل مستوى CMS فلا تستطيع طول معادٍ استعارة بايتات جاره
function ReadTLVWithin(const Data: AnsiString; ParentEnd: Integer;
  var Offset: Integer; out Tag: Byte; out Start, Len: Integer): Boolean;
begin
  Result := False;
  // لم يبق شيء داخل الأب: ارفض بدء قراءة
  if (Offset < 1) or (Offset >= ParentEnd) then
    Exit;
  if not DERReadTLV(Data, Offset, Tag, Start, Len) then
    Exit;
  // ‏Offset الآن بعد العنصر بموضع واحد؛ يجب ألا يتجاوز الأب
  Result := Offset <= ParentEnd;
end;

// كل مستوى يسجل نهايته الخاصة ويسلّمها إلى الأسفل:
//   OuterEnd    := end of ContentInfo         (RFC 5652 section 3)
//   ExplicitEnd := end of content [0] EXPLICIT
//   ContentEnd  := end of SignedData          (RFC 5652 section 5.1)
//   SignerEnd / InnerEnd for SignerInfo and issuerAndSerialNumber

الوحدة PDFlibCMSRead تمرر الآن تلك النهايات عبر ContentInfo، وغلاف [0] EXPLICIT، وحقول SignedData حتى signerInfos، و SignerIdentifier بصورتيه issuerAndSerialNumber و [0] subjectKeyIdentifier (RFC 5652 §5.3)، وحقول tbsCertificate المقروءة من كل شهادة مضمّنة عند مطابقة الموقّع. وداخل مجموعة الشهادات ومجموعة signerInfos، العنصر الذي يجتاز نهاية المجموعة يوقف الحلقة: تعيد PLExtractCMSCertificates الشهادات التي قبلتها فعلاً ولا تلصق أبداً بايتات crls أو signerInfos التالية على الأخيرة منها. ومطابقة المصدر والرقم التسلسلي تشترط النصفين معاً، لأن الرقم التسلسلي فريد داخل مصدر واحد فقط

لماذا ظهر 2.999.3 على هيئة 1.15.3؟

أول قوسين من OID يندمجان في معرّف فرعي واحد، لا في بايت واحد، وذلك المعرّف الفرعي ينثق بترميز base-128 مثل كل قوس آخر. تعرّفه X.690 §8.19.4 بوصفه 40 * arc1 + arc2؛ وكان DER_OID الأقدم يكتب تلك القيمة عبر Byte(...)، وهي صحيحة حتى 127 فقط، أي قيمة 2.47. وفي 2.999 يكون المجموع 1079، والصب إلى بايت يبقي 55، و55 يفك ترميزه إلى 1.15، فيسمّي المعرّف بهدوء فرعاً آخر من الشجرة. والقيم من 128 إلى 255 تفشل على نحو آخر: تطلق بايتاً واحداً ببت المتابعة مرتفعاً يبتلع القوس التالي. معظم معرفات PKI (1.2.840... و 2.5.29... و 0.4.0...) لا تبلغ الحد قط، ولهذا نجا العيب؛ أما أقواس joint-iso-itu-t من 2.48 فصاعداً تبلغه. ‏DER_OID تخدم المرمّز الخاص بالخصائص الموقّعة والمطابِق في DERFindExtensionByOID وفحص نوع محتوى SignedData دفعة واحدة، فالترميز الخاطئ كان يكسر الكتابة والبحث معاً

uses
  SysUtils, PDFlibASN1;

function Hex(const S: AnsiString): string;
var
  I: Integer;
begin
  Result := '';
  for I := 1 to Length(S) do
    Result := Result + IntToHex(Byte(S[I]), 2) + ' ';
  Result := Trim(Result);
end;

begin
  WriteLn(Hex(DER_OID('2.999.3')));               // 06 03 88 37 03
  WriteLn(Hex(DER_OID('2.47.1')));                // 06 02 7F 01
  WriteLn(Hex(DER_OID('2.48.1')));                // 06 03 81 00 01
  WriteLn(Hex(DER_OID('2.5.29.14')));             // 06 03 55 1D 0E
  WriteLn(Hex(DER_OID('1.2.840.113549.1.7.2')));  // 06 09 2A 86 48 86 F7 0D 01 07 02
end.
يدمج PDFlibPas ‏DER_OID أول قوسين من OID بوصفهما 40 * arc1 + arc2 وينثق المجموع بـ base-128 في UInt64، فيصبح 2.999.3 ‏06 03 88 37 03، بينما أبقى صب Byte القديم 55 وفكّ ترميز المعرّف بهدوء إلى 1.15.3
معظم أقواس PKI لا تبلغ الحد قط ولهذا نجا العيب — أقواس joint-iso-itu-t من 2.48 فما فوق تحتاج بايتين، والاختبار يبقي 2.47 و 2.48 على جانبيه

القيمة المدمجة محفوظة في UInt64 عن قصد. تحلل DER_OID الأقواس في Int64، فيمكن للقوس الثاني المشروع أن يبلغ Int64.MaxValue، وإضافة 80 من أجل arc1 = 2 تفيض على عدد صحيح 64-بت مؤشر. تحمل UInt64 ‏Int64.MaxValue + 80 دون لف، والمخزن المؤقت من عشرة بايت يتسع لمجموعات الـ 7 بت العشر التي تحتاجها قيمة 64 بت. ومتجهات الاختبار الجديرة بالاحتفاظ هي التي على جانبي الحد: 2.47 يجب أن تبقى بايتاً واحداً، و 2.48 يجب أن تصبح اثنين

ماذا يضمن تجوال CMS من جهة القراءة؟

يضمن PDFlibCMSRead البنية ولا شيء سواها: يعيد البايتات التي تجلس حيث تقول RFC 5652 إنها يجب أن تجلس، ولا يتحقق من توقيع أو هضم أو فترة صلاحية. التجوال لا يقبل إلا DER، فترفض DERReadTLV الأطوال غير المحددة وأرقام الوسوم متعددة البايتات، و CMS مرمّز بـ BER من موقّع غير ملتزم يبلّغ عن صفر شهادات بدل تخمين جزئي. وشهادات الخصائص (attribute certificates) والبدائل الأخرى في CertificateChoices تُتجاوز لأن لا شيء في المصب يستطيع استعمالها. أما التحقق التشفيري فيبقى مع الكود الذي يملكه، وهو يبدأ بفحوص تغطية البايتات الموصوفة في التوقيع بـ PAdES والتحقق من ByteRange في Delphi ويتواصل مع تصنيف ما تغيّر بعد توقيع PDF

الدرس الأوسع يسري على أي صيغة ثنائية: «داخل المخزن» خاصية أمان ذاكرة، و«داخل الأب» خاصية صواب، والمحلل يحتاج الاثنين. والتفكير نفسه في الأطوال المعادية يسري في تحصين محلل PDF بـ Pascal ضد الملفات الخبيثة. وواجهات استخراج الشهادات وبناء السلاسل والتحقق طويل الأمد التي ناقشتها هذه المقالة تأتي مع losLab PDF Library for Delphi الموجهة إلى Delphi و C++Builder و Lazarus