يحدد مكوّن PDFium بداية بنية CMS متداخلة من طول محتواها، لا بمشي عكسي فوق أوكتات الطول أبدًا، لأن البايت الذي يسبق المحتوى مباشرة هو أوكتة الطول الأخيرة ولا يقول شيئًا عن عدد ما يسبقها. يشتق CmsHeaderStart في FPdfCms.pas طول الرأس من ContentLen بدلًا من ذلك، وDER يجعل ذلك دقيقًا، وهذا ما يمنع AddSignatureTimestampToCms من إفساد كل CMS مجموعة شهاداته أطول من 127 بايت
الإطار هو ترقية PAdES B-T. سمة ختم توقيع زمني، السمة التي يعرفها البند 5.3 من ETSI EN 319 122-1 تحت OID 1.2.840.113549.1.9.16.2.14، يجب أن تهبط في unsignedAttrs التابع لـ SignerInfo الموصوف في البند 5.3 من RFC 5652، وبالتعريف لا يمكن إضافتها إلا بعد وجود قيمة التوقيع، لأن رمز الختم الزمني يُحسب فوق تلك القيمة. فالـ CMS مبني وموقَّع سلفًا حين يصل الرمز. إضافة سمة واحدة تغير طول SignerInfo، فيتغير طول مجموعة signerInfos SET، ثم SignedData، ثم غلاف [0] EXPLICIT، ثم ContentInfo الخارجي. كل رأس محتقن يجب أن يعاد إصداره، وكل ما ليس على ذلك المسار يجب أن يُنقل عبر البايتات بايتًا ببايت. يغطي شرح B-LT وB-LTA ما يكسبك الرمز؛ وهذه المقالة عن الأربعة بايتات أمام مجموعة الشهادات التي ظل إعادة البناء يخطئ فيها
لماذا تحتاج إضافة ختم زمني إزاحة وسم أحد الأشقاء؟
لأن إعادة البناء تعيد استخدام أربعة أشقاء لمجموعة signerInfos SET حرفيًا، والقارئ يبلّغ أين محتواها لا أين وسمها. يسلم TDerReader.ReadTlv بايت الوسم وإزاحة المحتوى وطول المحتوى وإزاحة TLV التالية. ذلك السطح الصحيح للنزول داخل بنية، لكن لنسخ عنصر كامل تحتاج الأوكتة التي يجثم عليها وسمه، والشيء الوحيد الذي يحمله المستدعي هو ContentOffs. وُجد CmsSliceTlv لجسر تلك الفجوة: أمام إزاحة محتوى وطول يعيد الوسم وأوكتات الطول والمحتوى في مخزن واحد، ويستدعيه AddSignatureTimestampToCms لـ OID الـ contentType، وversion INTEGER، ومجموعة digestAlgorithms SET، وتسلسل encapContentInfo SEQUENCE، وعند وجودها، مجموعة certificates [0]
// داخل AddSignatureTimestampToCms: انزل وشذّب الأشقاء حرفيًا
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> Byte(asnSequence)) then
raise Exception.Create('CMS: encapContentInfo SEQUENCE expected');
SdEncapTlv:= CmsSliceTlv(CmsDer, CO, CL);
R.Position:= CN;
// certificates [0] اختيارية
HasCerts:= (R.Position< SdEnd) and (CmsDer[R.Position]= $A0);
if HasCerts then
begin
if not R.ReadTlv(Tag, CO, CL, CN) or (Tag<> $A0) then
raise Exception.Create('CMS: certificates [0] malformed');
SdCertsTlv:= CmsSliceTlv(CmsDer, CO, CL); // الوسم + أوكتات الطول + المحتوى
R.Position:= CN;
end;
من تلك الشذب الخمس، أربع صغيرة: OID ذو إحدى عشرة بايتة، وINTEGER ذو ثلاث بايتات، ومجموعة خوارزمية ملخص من سبع عشرة بايتة، وencapContentInfo منفصل من ثلاث عشرة بايتة. مجموعة الشهادات هي الوحيدة التي تحمل شهادة الموقع وسلسلته، وشهادة X.509 حقيقية تمتد إلى عدة مئات من البايتات على الأقل. مجموعة الشهادات إذن هي الشذبة الوحيدة التي تدخل أوكتات طولها الصيغة الطويلة يومًا، وهي الشذبة التي لم يستطع المساعد القديم تحديد موضعها
لماذا لا يمكن مشي أوكتات طول DER عكسيًا؟
لأن عدد أوكتات الطول مخزن في أولها، ومن يقرأ من المحتوى عكسيًا يقابل الأخيرة أولًا. يعرف البند 8.1.3.4 من X.690 الصيغة القصيرة: أوكتة واحدة، والبت 8 مطفأ، والبتات 7 إلى 1 تحمل طولًا من 0 إلى 127. ويعرف البند 8.1.3.5 الصيغة الطويلة: أوكتة ابتدائية مشعلًا البت 8 تعطي بتاتها 7 إلى 1 عدد الأوكتات اللاحقة، تليها تلك الأوكتات حاملة الطول بوصفه عددًا صحيحًا بلا إشارة مرتبًا بالبايت الأعلى أولًا. لا شيء في القاعدة يوسم أوكتة لاحقة بوصفها لاحقة. بت 8 عندها بت مقدار كأي بت آخر، فالمشي العكسي الذي يختبر البت الأعلى من Buf[ContentOffs- 1] يختبر بت بيانات ثم يقرأ بتاته السبع الدنيا بوصفها عددًا
// المساعد القديم، يُعطى إزاحة المحتوى وحدها
function CmsHeaderStart(const Buf: TBytes; ContentOffs: Integer): Integer;
var
P, LenByte, LongLen: Integer;
begin
P:= ContentOffs- 1; // يهبط على أوكتة الطول الأخيرة
if P< 0 then
Exit(ContentOffs);
LenByte:= Buf[P];
if (LenByte and $80)= 0 then // ذو معنى للأولى فقط
Result:= P- 1
else
begin
LongLen:= LenByte and $7F;
Result:= P- LongLen- 1;
end;
end;
// رأس مجموعة شهادات بحجم 1500 بايت: A0 82 05 DC
// Buf[ContentOffs- 1]= $DC -> bit 8 set, $DC and $7F= 92
// Result= ContentOffs- 94 (الوسم عند ContentOffs- 4)
خذ رأس مجموعة شهادات تحمل 1500 بايت من الشهادات، A0 82 05 DC. يهبط المشي على DC، ويرى بتًا أعلى مشعلًا، ويستخرج 92 من بتاته السبع الدنيا، ويبلغ أن الوسم على بعد 94 بايتة قبل المحتوى، وهو في الحقيقة على بعد 4 بايتات قبلها. في SignedData مبني بيد BuildSignedData تجثم محتويات مجموعة الشهادات على بعد بضعة عشرات من البايتات فقط داخل الـ CMS، فلم تكن الإزاحة المحسوبة مبكرة فحسب بل سالبة، وكان الكود القديم يحرس ContentOffs- 1 من النزول تحت الصفر لا نتيجته النهائية. ثم أخذت CmsSliceTlv شذبة أطول من العنصر بتسعين بايتة فأكثر، تبدأ قبل المخزن، وحمل SignedData المعاد بناؤه تلك الشذبة في موضع مجموعة شهاداته. وطول من ثلاث أوكتات صادفت أوكتته الأخيرة دون $80، مثل A0 82 05 10، فشل بالاتجاه الآخر: أخذه المشي أوكتة صيغة قصيرة وبدأ الشذبة عند 05، متأخرًا بايتتين وداخل أوكتات الطول، بلا وسم إطلاقًا. كانت النتيجة خاطئة في الحالتين، ولم يتبدل إلا الاتجاه
ما الذي يضمنه DER الذي يجعل الاشتقاق الأمامي دقيقًا؟
يضمن DER أن ترميز الطول دالة خالصة في الطول. يقيد البند 10.1 من X.690 DER بالصيغة المحددة ويشترط العدد الأدنى من الأوكتات، فينزع الحريتين اللتين يسمح بهما BER: الصيغة غير المحددة، وحشو طول بالصيغة الطويلة بأوكتات أصفار بادئة. وفق تلك القاعدة طول محتوى دون 128 له أوكتة طول واحدة بالضبط، وأي طول آخر له أوكتة ابتدائية واحدة زائد عدد الأوكتات اللاحقة الذي يحتاجه الطول من البايتات الدالة. ومستدعي CmsHeaderStart يحمل أصلًا ContentLen، لأن ReadTlv أعادها للتو، فطول الرأس قابل للحساب دون النظر إلى بايت واحد من المخزن
// المساعد المشحون: اشتق الرأس من طول المحتوى.
// وفق X.690 10.1 أوكتات الطول دالة في ContentLen
function CmsHeaderStart(const Buf: TBytes; ContentOffs, ContentLen: Integer): Integer;
var
LengthOctets, Remaining: Integer;
begin
if ContentLen< 128 then
LengthOctets:= 1 // الصيغة القصيرة، X.690 8.1.3.4
else
begin
LengthOctets:= 1; // الأوكتة الابتدائية، X.690 8.1.3.5
Remaining:= ContentLen;
while Remaining> 0 do
begin
Inc(LengthOctets); // واحدة لكل بايت دال
Remaining:= Remaining shr 8;
end;
end;
Result:= ContentOffs- LengthOctets- 1;
if Result< 0 then
Result:= ContentOffs;
end;
تفصيلان يجعلان هذا آمنًا لا معقولًا فحسب. أولًا، افتراض أن المدخل DER مفروض من المنبع: يرفض TDerReader.TryReadTlvAt، الذي بُني ReadTlv عليه، الصيغة غير المحددة، ويرفض طولًا بالصيغة الطويلة أول أوكتاته اللاحقة صفر، ويرفض أوكتة لاحقة مفردة دون $80. الـ TLV الذي يبلغ CmsSliceTlv اجتاز تلك الفحوص سلفًا، فلا يمكن لطول BERي غير الأدنى أن يبلغ الاشتقاق ويكذبه. وثانيًا، الإسقاط الاحتياطي للنتيجة السالبة يحرس الآن الجواب الحقيقي لا وسيطًا. ويستحق القول إن القارئ عرف إزاحة الوسم طوال الوقت: يحمل TDerTlv كلا Offset وHeaderLength، وسطح ReadTlv ذو المعاملات الأربعة الخارجة فقط هو الذي يهمدهما. إعادتهما كانت ستكون الواجهة الأنظف على المدى الطويل؛ أبقى الإصلاح المشحون ذلك السطح سليمًا ويجعل المساعد صحيحًا بمعاييره هو
لماذا نجحت اختبارات الختم الزمني والخطأ في مكانه؟
لأن كل شهادة عينة كانت قصيرة بما يكفي للصيغة القصيرة، والمشي العكسي صحيح لذلك الحال بالضبط. يبني Tests.PadesTimestamp.pas شهادة الموقع بـ SetLength(SignerCertDer, 32) في اختبار وبـ 64 في آخر، معبأة بمنحدر بايتات. مجموعة شهادات بحجم 32 بايتة ترمز A0 20 وواحدة بحجم 64 ترمز A0 40، أوكتة طول واحدة لكل منهما. يهبط المشي العكسي من المحتوى على تلك الأوكتة الواحدة، وبتها الأعلى مطفأ لأنها أول أوكتة الطول ووحدها، فيجيب المساعد إجابة صحيحة للسبب الخطأ. وكان جناح الـ 1414 حالة أخضر، وُلّد الـ CMS المختوم زمنيًا، وأبلغ مدقق المرحلة الأولى عن B-T، وكل واحد من تلك الفحوص شُغّل مقابل مجموعة شهادات لم يحتها مستند حقيقي قط
القاعدة العامة هي الجزء المفيد. كلما اعتمد مسار كود على كيفية ترميز طول، يجب أن تعبر العينة حد الترميز، ولمعنى DER ذلك محتوى أطول من 127 بايت يجبر الصيغة الطويلة، ومن الأفضل أطول من 255 بايت أيضًا يجبر أوكتة لاحقة ثانية. وينطبق الانضباط نفسه على الحالة الأخرى في تلك المراجعة حيث لم يستطع التحقق الذاتي رؤية انحراف DER: كان الـ SET OF غير المرتب في signedAttrs خفيًا عن جولة من المصدر نفسه لسبب بنيوي مطابق، إذ مارس الاختبار مدخلات لا يختلف عليها الكود الخاطئ عن الصحيح فحسب. يستدعي المخطط أدناه مساعد الشذب مباشرة، أي تصديره من FPdfCms.pas لبناء الاختبار؛ ويُبلغ الحد نفسه عبر السطح العام بإعطاء BuildSignedData شهادة سلسلة من كل حجم وإعادة تحليل النتيجة المختومة
// ثبّت الحد: الشذبة عبر رأس بالصيغة الطويلة يجب أن تبدأ عند الوسم
const
Lens: array[0..6] of Integer= (127, 128, 255, 256, 1500, 65535, 65536);
procedure TCmsSliceTests.HeaderStart_LongFormLengths;
var
W: TDerWriter;
Content, Tlv: TBytes;
I, Len: Integer;
begin
W:= TDerWriter.Create;
try
for I:= Low(Lens) to High(Lens) do
begin
Len:= Lens[I];
SetLength(Content, Len);
Tlv:= W.Wrap($A0, Content); // A0 7F / A0 81 80 / A0 82 05 DC ...
W.Clear;
// يبدأ المحتوى بعد الرأس مباشرة؛ يجب أن تكون الشذبة TLV كاملة
Assert.AreEqual(Length(Tlv),
Length(CmsSliceTlv(Tlv, Length(Tlv)- Len, Len)),
'slice through header of a '+ IntToStr(Len)+ '-byte content');
end;
finally
W.Free;
end;
end;
أين ما تزال إعادة البناء ترسم حدودها
كُتب AddSignatureTimestampToCms للـ CMS الذي يصدره BuildSignedData، وحدوده تلتبع من ذلك. يتوقع المشي SignerInfo واحدًا ويعيد إصداره وحده، فـ CMS أجنبي متعدد الموقعين سيعود بموقع واحد؛ ويتعرف مجموعة certificates [0] الاختيارية لا مجموعة crls [1]، وـ CMS يحملها يفشل فشلًا عالي الصوت باستثناء signerInfos SET expected بدل شذب خاطئ هادئ. وتحمل unsignedAttrs الجديدة سمة واحدة، فقاعدة ترتيب SET OF في البند 11.6 من X.690 تتحقق ببساطة ولا تحتاج فرزًا. والجزء الموقع لم يُمس بالبناء: تُنسخ بادئة SignerInfo حتى سلسلة OCTET STRING للتوقيع حرفيًا، ولهذا يرى مدقق يعيد تلخيص signedAttrs البايتات نفسها قبل إضافة الختم وبعده. وحين ما يزال أحدهم يرفض المستند، فالأسباب عادةً في مكان آخر وتستحق قائمتها الخاصة
قارئ DER والكاتب وبانِ CMS وحقن الختم الزمني هذا كلها تُشحن مصدر Pascal مع مكوّن PDFium لدلفي، وخطأ من هذا الطراز هو الحجة لذلك: عندما يخرج SignedData معاد البناء أطول بتسعين بايتة، تريد أن تقرأ المساعد الذي قطع الشذبة وبند X.690 الذي أخطأ قراءته، لا أثر رصة من صندوق أسود