يبني مكوّن PDFium كتلة CMS signedAttrs عبر TDerWriter.SetOfSorted، لأن البند X.690 11.6 يفرض إخراج عناصر SET OF بترتيب تصاعدي حسب ترميزاتها DER الخاصة. اجمعها بترتيب الإنشاء بدلًا من ذلك وستحصل على توقيعات يقبلها المحقق الخاص بك وترفضها كل أدوات التحقق من PAdES التابعة لجهات ثالثة
الجملة الأخيرة هي السبب كله في نجاة هذا الخلل من مجموعة اختبارات الانحدار الكاملة. مسار التوقيع مغطى بالاختبارات، ومسار التحقق مغطى كذلك، والدورة الكاملة خضراء على كل المنصات، ومع ذلك لم يكن الناتج DER صالحًا. الاختبار الذي يوقع بمحررك ويتحقق بقارئك لا يمكنه رصد انحراف عن قواعد الترميز، لأن الطرفين يشتركان في الانحراف نفسه
ماذا يطلب البند X.690 11.6 فعليًا
يجب فرز عناصر قيمة SET OF، ومفتاح الفرز هو الترميز DER الكامل لكل عنصر، وتُقارن كسلسلة octet غير موقعة، مع تقديم الترميز الأقصر أولًا عندما يكون بادئة للآخر. هذا قيد كانوني وليس مجرد اقتراح: DER يقبل ترميزًا واحدًا فقط لأي قيمة معطاة، لذلك SET OF غير مرتبة ليست ترميزًا مختلف الترتيب لنفس القيمة، بل ليست ترميز DER إطلاقًا. ولأن وسوم ASN.1 تأتي أولًا في بنية TLV، تتقرر المقارنة عادة داخل أول octetين، وبما أن كل عنصر في قائمة سمات CMS يبدأ بـ 30، وسم SEQUENCE، يُحسم التعادل عند octet الطول. لذلك يبدو الترتيب المطلوب وكأنه ترتيب للسمات حسب الحجم، وهو أمر يستحق المعرفة حين تحدق في تفريغ hex متسائلًا لماذا يبدو الترتيب عشوائيًا
لماذا تكون أصغر مجموعة سمات خارج الترتيب منذ البداية؟
لأن ترتيب الإنشاء الطبيعي والترتيب المرمَّز يختلفان منذ أول توقيع تنتجه، دون أي سمات اختيارية. يضيف BuildSignedAttrs في FPdfCms.pas السمات content-type ثم message-digest ثم signing-certificate-v2 ثم cms-algorithm-protection. مع تجزئات SHA-256 وخوارزمية توقيع RSA، تبدأ TLVs السمات الأربع بهذه البايتات
// ترتيب الإنشاء الذي ينتجه BuildSignedAttrs
// content-type 30 18 (24 content octets)
// message-digest 30 2F (47)
// signing-certificate-v2 30 37 (55)
// cms-algorithm-protection 30 2D (45)
//
// ترتيب DER المطلوب وفق X.690 11.6
// 30 18 content-type
// 30 2D cms-algorithm-protection
// 30 2F message-digest
// 30 37 signing-certificate-v2
اثنتان من السمات الأربع تنتقلان. يجب أن تسبق cms-algorithm-protection عند 30 2D سمة signing-certificate-v2 عند 30 37، وعليها أيضًا أن تسبق message-digest عند 30 2F. إذن أصغر مجموعة سمات PAdES B-B مطابقة يمكن لمكوّن PDFium إصدارها كانت خارج الترتيب أصلًا في الكود القديم، قبل أن يضيف أحد signing-time أو commitment-type-indication أو signature-policy-identifier. لم تكن هناك أبدًا إعدادات لا يظهر فيها هذا الخلل
لماذا تنجح اختباراتنا بينما تفشل كل أدوات التحقق الخارجية؟
لأن عائلتي المحققين تحسبان تجزئة الرسالة على بايتات مختلفة. ينص البند 5.4 من RFC 5652 على أن مدخل عملية التوقيع هو الترميز DER لقيمة SignedAttributes، مع استبدال وسم IMPLICIT [0] بوسم SET 31، والعبارة التي تحسم كل شيء هي الترميز DER وليس البايتات التي استلمتها. محقق منصة Windows الذي يُستدعى عبر CryptVerifyDetachedMessageSignature يأخذ البايتات المستلمة، يبدّل الوسم الأول ويجزّئها كما وصلت، فلا دخل للترتيب أصلًا، وتُجزَّأ SET OF غير مرتبة إلى نفس ما حسبه الموقّع بالضبط. أما OpenSSL وBouncyCastle، اللذان تُبنى عليهما أدوات التحقق الأوروبية واسعة الانتشار، فيحللان مجموعة السمات إلى بنى ويعيدان الترميز قبل التجزئة. إعادة الترميز تطبق الفرز، فلا يعود التجزؤ مطابقًا لما وُقّع عليه، ويُعلَن التوقيع معطوبًا بدلًا من أنه ترميز خاطئ، وهو ما يرسلك في مطاردة عبر مواد المفاتيح وسلاسل الشهادات بحثًا عن خلل يعيش في دالة مقارنة
جاوز هذا الخلل الواحد إلى التعميم، لأن هذا هو الجزء المكلف. دورة كاملة من نفس الأصل محصنة ضد أي انحراف عن مواصفة توافقية يتشاركها طرفا كودك؛ فهي تثبت أن محررك وقارئك متفقان مع بعضهما، وهي ليست الخاصية التي تهم أحدًا. لأي شيء يغادر العملية بصيغة wire، الاختبار الانحداري الوحيد ذو المعنى هو الذي يثبت البايتات مقابل المواصفة أو يشغّل تطبيقًا خارجيًا على ناتجك. خلفية التحقق من CMS عبر OpenSSL تستحق مكانها هنا لسبب ثانٍ يتجاوز قابلية النقل: فهي قارئ مستقل حقًا لـ DER الذي تنتجه أنت
الإصلاح، وأين كان موجودًا أصلًا
كان لدى محرر DER النسخة المرتبة طوال الوقت. يكشف TDerWriter في FPdfAsn1.pas عن كلا الوجهين: SetOf الذي يغلّف مخزن محتوى مسبوق التجميع بوسم SET ويحافظ على أي ترتيب سُلّم له، وSetOfSorted الذي يأخذ العناصر منفردة كـ TDerByteArrays ويرتبها بفرز دمجي حسب ترميزاتها قبل التغليف. كود MAC في FPdfMac.pas كان يبني مجموعة سماته المصادَق عليها بـ SetOfSorted أصلًا؛ فقط موقّع CMS لم يكن كذلك. إبقاء العناصر كمصفوفة حتى استدعاء التغليف نفسه هو النصف البنيوي من الإصلاح، لأن السمات ما إن تُجمَّع في مخزن واحد حتى تختفي معلومات الترتيب ولا يمكن لأي عناية لاحقة استعادتها. واجهة محرر تقدم SetOf(Content: TBytes) فقط للـ SET OF هي واجهة تدعو لهذا الخلل؛ إن التحميل الزائد الذي يأخذ مصفوفة هو ما يجعل السلوك الصحيح قابلًا للتعبير
// قبل: تحتفظ العناصر بالترتيب الذي ألحقتها به AddAttr
Result:= W.SetOf(ConcatMany(AttrList));
// بعد: يُطبَّق ترتيب X.690 11.6 عند نقطة الترميز
Result:= W.SetOfSorted(AttrList);
SET OF تُرتب، SEQUENCE OF لا تُرتب
هذا هو التمييز الذي يُخلط فيه أكثر من غيره، وقلبه رأسًا على عقب مضر بقدر تجاهله. SEQUENCE OF مجموعة مرتبة: ترتيبها جزء من القيمة ويجب ألا تفرزها أبدًا. أما SET OF فهي غير مرتبة كقيمة، لذا يفرض DER ترتيبًا كانونيًا ليجعل الترميز فريدًا. فرز SEQUENCE OF يفسد البيانات؛ وترك SET OF بدون فرز ينتج شيئًا ليس DER
- SET OF يجب فرزها:
SignedAttributesوUnsignedAttributesفيSignerInfo، ومجموعةdigestAlgorithms، ومجموعتاcertificatesوcrlsفيSignedData، ومجموعةsignerInfos، ومجموعة القيم داخل كلAttributeعلى حدة، ومجموعة السمات داخل كلRelativeDistinguishedNameفي اسم X.501 - SEQUENCE OF لا تُفرز أبدًا: قائمة RDN التي تكوّن
Name، وحقلcertsفيSigningCertificateV2، وقوائم الامتدادات، وقوائم الأسماء العامة بداخلها
RDN هي SET OF جالسة داخل SEQUENCE OF في المستوى الأعلى، لذا يحتوي الاسم المميز على القاعدتين معًا ضمن مستويين من التداخل. هذا التصميم يفسر أيضًا لماذا المجموعات أحادية العنصر متسامحة إلى هذا الحد: مجموعة بعنصر واحد مرتبة تلقائيًا، ولهذا كانت مجموعات قيم السمات المبنية بـ W.SetOf داخل BuildSignedAttrs صحيحة كما كُتبت ولم تحتج إلى تغيير. الخلل يسكن فقط حيث تحوي مجموعة عنصرين أو أكثر فعلًا، وRDNs متعددة القيم نادرة بما يكفي ليتفلت معظم مبرمجي الأسماء من الخطأ نفسه لسنوات
كيف تمنع هذا من العودة؟
اكتب التأكيدات على البايتات، لا على الأحكام. أرخص فحص دائم هو مراجعة ذاتية تتجول في ناتجها: حلّل signedAttrs المُصدر، وقارن كل عنصر بسابقه باستخدام مقارنة سلسلة octet غير الموقعة نفسها التي يستخدمها المحرر، وأفشل البناء إذا كان أي زوج خارج الترتيب. تعمل في ميكروثوانٍ، ولا تحتاج مكتبة خارجية، وبخلاف دورة التحقق تختبر الخاصية التي تنص عليها المواصفة فعلًا
// افحص عناصر SET OF وتأكد من ترتيب البند X.690 11.6
// Members هي القائمة الممررة إلى SetOfSorted، أو ناتج
// إعادة تحليل كتلة 31 xx / A0 xx المُصدرة
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
I: Integer;
begin
for I:= 1 to Length(Members)- 1 do
if CompareDerBytes(Members[I- 1], Members[I])> 0 then
Exit(False);
Result:= True;
end;
// في اختبار التوقيع، لا تتوقف عند «محققنا قال صالح»
// ثبّت البايتات التي جزّأها الموقّع
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');
حدّان يستحقان الذكر بوضوح. فرز signedAttrs لا يجعل التوقيع متوافقًا بمفرده، لأن كل شيء آخر في الحاوية يجب أن يصمد أيضًا، وأسباب رفض المحققين لتوقيعات PAdES السليمة ظاهريًا متنوعة بما يكفي لتعبئة نقاشها الخاص. وSET OF المرتبة خاصية لما تكتبه لا لما تقرؤه: ما يزال على مكوّن PDFium قبول توقيع وارد تصل signedAttrs فيه غير مرتبة، لأن مثل هذه الملفات موجودة في البرية والبند 5.4 من RFC 5652 يطلب من المحقق إعادة الترميز لا الرفض. صارم في المخرجات، متسامح في المدخلات، كالعادة. محرر DER وباني CMS ومسار التوقيع الموصوف في شرح PAdES B-B خطوة بخطوة كلها تُشحن كمصدر Pascal قابل للقراءة مع مكوّن PDFium لدلفي، وهذا هو بيت القصيد في شحن المصدر لمكتبة توقيع أصلًا: حين يخالفك محقق في مكان ما من أوروبا حول مستندك، يمكنك قراءة البايتات الدقيقة التي أصدرها كودك والبند الدقيق الذي تخالفه، بدلًا من فتح تذكرة والانتظار