مقاله فنی

اکتت‌های طول DER را هرگز به عقب نپیمایید: کامپوننت PDFium

کامپوننت PDFium شروع یک ساختار CMS تودرتو را از طول محتوایش پیدا می‌کند، هرگز با پیمایش به عقب روی اکتت‌های طول، چون بایت درست قبل از محتوا آخرین اکتت طول است و هیچ نمی‌گوید که چند تا قبلش آمده. CmsHeaderStart در FPdfCms.pas به‌جایش طول هدر را از ContentLen استخراج می‌کند، که DER دقیقش می‌کند، و همین چیزی است که نمی‌گذارد AddSignatureTimestampToCms هر CMSی را که مجموعهٔ گواهی‌اش از 127 بایت بلندتر است خراب کند

موضوع، ارتقای PAdES B-T است. یک اتریبیوت signature-time-stamp، همان که ETSI EN 319 122-1 بند 5.3 زیر OID 1.2.840.113549.1.9.16.2.14 تعریفش می‌کند، باید در unsignedAttrs آن SignerInfo بنشیند که RFC 5652 بند 5.3 توصیفش می‌کند، و طبق تعریف فقط بعد از وجود یافتن مقدار امضا می‌تواند اضافه شود، چون توکن timestamp روی همان مقدار محاسبه می‌شود. پس وقتی توکن می‌رسد CMS از قبل ساخته و از قبل امضا شده است. اضافه کردن یک اتریبیوت طول SignerInfo را عوض می‌کند، که طول SET مربوط به signerInfos را عوض می‌کند، بعد SignedData، بعد پوشش EXPLICITی [0]، بعد ContentInfo بیرونی. هر هدر محیطی باید از نو بیرون داده شود، و هر چیزی که روی این مسیر نیست باید بایت‌به‌بایت منتقل شود. قدم‌به‌قدمِ B-LT و B-LTA پوشش می‌دهد که آن توکن چه چیزی برایت می‌خرد؛ این مقاله دربارهٔ همان چهار بایتی است که جلوی مجموعهٔ گواهی می‌نشیند و بازسازی مدام غلطش می‌گرفت

چرا اضافه کردن یک timestamp به آفست تگ یک خواهر نیاز دارد؟

چون بازسازی چهار خواهر از SET مربوط به signerInfos را عیناً بازاستفاده می‌کند، و reader گزارش می‌دهد که محتوایشان کجاست، نه این‌که تگشان کجاست. TDerReader.ReadTlv بایت تگ، آفست محتوا، طول محتوا و آفست TLV بعدی را برمی‌گرداند. این سطح درستی است برای پایین رفتن در یک ساختار، ولی برای کپی کردن یک عنصر کامل به اکتتی نیاز داری که تگش رویش نشسته، و تنها چیزی که فراخوان در دست دارد ContentOffs است. CmsSliceTlv وجود دارد تا همین شکاف را پر کند: با گرفتن یک آفست و طول محتوا، تگ و اکتت‌های طول و محتوا را به‌صورت یک بافر برمی‌گرداند، و AddSignatureTimestampToCms آن را برای OID مربوط به contentType و INTEGER مربوط به version و SET مربوط به digestAlgorithms و SEQUENCE مربوط به encapContentInfo و در صورت وجود مجموعهٔ 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 سه‌بایتی، یک مجموعهٔ الگوریتم digest هفده‌بایتی، یک encapContentInfo جداشدهٔ سیزده‌بایتی. مجموعهٔ گواهی همانی است که گواهی امضاکننده و زنجیره‌اش را حمل می‌کند، و یک گواهی X.509 واقعی حداقل به چند صد بایت می‌رسد. پس مجموعهٔ گواهی تنها برشی است که اکتت‌های طولش هرگز در فرم بلند می‌افتند، و همان برشی است که helper قدیمی نمی‌توانست پیدا کند

اضافه کردن یک timestamp مربوط به PAdES B-T در CMS چه چیزی را از نو می‌سازد: CmsSliceTlv مقادیر contentType و version و digestAlgorithms و encapContentInfo و مجموعهٔ گواهی را بایت‌به‌بایت کپی می‌کند، مجموعهٔ گواهی تنها برشی است که آن‌قدر بلند است که از فرم کوتاه طول بیرون بزند، و هر هدر محیطی از SignerInfo تا ContentInfo از نو بیرون داده می‌شود
بخش امضاشده طبق ساختار دست‌نخورده می‌ماند چون پیشوند SignerInfo تا OCTET STRING امضا عیناً کپی می‌شود، پس validatory که signedAttrs را دوباره digest می‌کند بایت‌های یکسانی را قبل و بعد از نشستن timestamp می‌بیند

چرا اکتت‌های طول DER را نمی‌توان به عقب پیمود؟

چون تعداد اکتت‌های طول در اولین آن‌ها ذخیره شده، و وقتی از محتوا به عقب می‌خوانی اول به آخرین‌شان می‌رسی. X.690 بند 8.1.3.4 فرم کوتاه را تعریف می‌کند: یک اکتت، بیت 8 صفر، بیت‌های 7 تا 1 حامل طولی از 0 تا 127. بند 8.1.3.5 فرم بلند را تعریف می‌کند: یک اکتت آغازین با بیت 8 ست که بیت‌های 7 تا 1ش تعداد اکتت‌های بعدی را می‌دهند، و بعد همان اکتت‌ها که طول را به‌صورت یک عدد صحیح بدون علامت big-endian حمل می‌کنند. هیچ‌چیز در این قاعده یک اکتت بعدی را به‌عنوان «بعدی» علامت نمی‌زند. بیت 8ش مثل هر بیت دیگری یک بیت مقدار است، پس پیمایشی به عقب که بیت بالای Buf[ContentOffs- 1] را می‌آزماید دارد یک بیت داده را می‌آزماید و بعد هفت بیت پایینش را به‌عنوان یک شمارش می‌خواند

// helper قدیمی، که فقط آفست محتوا را می‌گرفت
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  -> بیت 8 ست، $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 را نمی‌توان به عقب پیمود: خواندن A0 82 05 DC از انتها روی آخرین اکتت یعنی DC می‌افتد که بیت بالای ستش یک شمارش بی‌پا و بی‌سر 92 می‌دهد و تگ را 94 اکتت زودتر می‌گذارد، در حالی که A0 82 05 10 از طرف دیگر شکست می‌خورد و برش را دو اکتت دیرتر داخل اکتت‌های طول شروع می‌کند
CmsHeaderStart قدیمی از تفریق میانی محافظت می‌کرد نه از نتیجهٔ نهایی‌اش، پس یک برش حتی می‌توانست پیش از بافر شروع شود، و SignedData بازسازی‌شده همان برش را جایی حمل می‌کرد که مجموعهٔ گواهی‌اش تعلق داشت

DER چه چیزی را تضمین می‌کند که استخراج رو-به-جلو را دقیق می‌کند؟

DER تضمین می‌کند که کدگذاری طول یک تابع محض از طول است. X.690 بند 10.1 DER را به فرم معیّن محدود می‌کند و حداقل تعداد اکتت را می‌خواهد، که دو آزادی BER را حذف می‌کند: فرم نامعیّن، و پر کردن یک طول فرم-بلند با اکتت‌های صفر ابتدایی. زیر آن قاعده، یک طول محتوای زیر 128 دقیقاً یک اکتت طول دارد، و هر طول دیگری یک اکتت آغازین به‌علاوهٔ دقیقاً همان تعداد اکتت بعدی که طول به بایت‌های معنادار نیاز دارد. فراخوان CmsHeaderStart از قبل ContentLen را در دست دارد، چون ReadTlv تازه برگردانده‌اش، پس طول هدر بدون نگاه کردن به حتی یک بایت از بافر قابل محاسبه است

استخراج رو-به-جلو که DER تضمینش می‌کند: طول محتوای 127 به‌صورت A0 7F کد می‌شود، 128 به‌صورت A0 81 80، 255 به‌صورت A0 81 FF، 256 به‌صورت A0 82 01 00 و 1500 به‌صورت A0 82 05 DC، پس طول هدر فقط از ContentLen می‌آید و TryReadTlvAt از قبل هر فرم BER غیرمینیمال را رد کرده
fixtureهای ساخته‌شده از گواهی‌های 32 و 64 بایتی داخل فرم کوتاه ماندند، جایی که پیمایش به عقب به دلیل غلط جواب درست می‌دهد، و همین دلیل است که مجموعهٔ مرزی حالا از 127 و 128 و 255 و 256 بایت عبور می‌کند
// helper منتشرشده: هدر را از طول محتوا استخراج کن
// زیر 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 نمی‌تواند به استخراج برسد و دروغش کند. دوم، fallback برای نتیجهٔ منفی حالا از جواب واقعی محافظت می‌کند، نه از یک مقدار میانی. ارزش دارد بگوییم که reader از اول آفست تگ را می‌دانست: TDerTlv هم Offset و هم HeaderLength را حمل می‌کند، و فقط سطح ReadTlv با چهار پارامتر خروجی آن‌ها را می‌اندازد. برگرداندن‌شان رابط بلندمدت تمیزتری می‌بود؛ fix منتشرشده آن سطح را دست‌نخورده نگه می‌دارد و helper را با معیارهای خودش درست می‌کند

چرا تست‌های timestamp با وجود باگ پاس می‌شدند؟

چون هر گواهی fixture آن‌قدر کوتاه بود که از فرم کوتاه استفاده کند، و پیمایش به عقب دقیقاً برای همین حالت درست است. Tests.PadesTimestamp.pas گواهی امضاکننده‌اش را در یک تست با SetLength(SignerCertDer, 32) و در دیگری با 64 می‌سازد، پر شده با یک رمپ بایتی. یک مجموعهٔ گواهی 32 بایتی به‌صورت A0 20 کد می‌شود و یک 64 بایتی به‌صورت A0 40، هر کدام یک اکتت طول تکی. پیمایش به عقب از محتوا روی همان یک اکتت می‌افتد، بیت بالایش صفر است چون اولین و تنها اکتت طول است، و helper به دلیل غلط جواب درست می‌دهد. مجموعهٔ 1414 موردی سبز بود، CMS تایم‌استمپ‌شده پارس می‌شد، validator مرحلهٔ 1 مقدار B-T را گزارش می‌کرد، و هر یک از آن بررسی‌ها روی مجموعهٔ گواهی‌ای اجرا می‌شد که هیچ سند واقعی هرگز در خود نداشته

قاعدهٔ کلی همان بخش مفید است. هر وقت یک مسیر کد به نحوهٔ کدگذاری یک طول وابسته باشد، fixture باید از مرز کدگذاری عبور کند، و برای DER این یعنی محتوای بلندتر از 127 بایت که فرم بلند را تحمیل می‌کند، و ترجیحاً بلندتر از 255 بایت هم که یک اکتت بعدی دوم را تحمیل می‌کند. همین نظم در مورد دیگری از آن بازبینی هم اعمال می‌شود که در آن تأیید خودی نمی‌توانست یک انحراف DER را ببیند: SET OF نامرتب در signedAttrs برای یک round trip هم‌مبدأ به دلیل ساختاراً یکسانی نامرئی بود، تست فقط روی ورودی‌هایی تمرین می‌کرد که در آن‌ها کد غلط و کد درست توافق دارند. طرح زیر helper برش را مستقیم صدا می‌زند، که یعنی برای بیلد تست از FPdfCms.pas export شود؛ همین مرز از سطح عمومی هم قابل دسترسی است، با دادن یک گواهی زنجیره از هر اندازه به 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 در X.690 بند 11.6 به‌صورت بدیهی برآورده است و نیازی به sort ندارد. و بخش امضاشده طبق ساختار دست‌نخورده است: پیشوند SignerInfo تا OCTET STRING امضا عیناً کپی می‌شود، و همین دلیل است که validatory که signedAttrs را دوباره digest می‌کند همان بایت‌ها را قبل و بعد از اضافه شدن timestamp می‌بیند. وقتی یکی باز هم سند را رد می‌کند، علت‌ها معمولاً جای دیگری‌اند و ارزش یک چک‌لیست جداگانه دارند

reader مربوط به DER و نویسنده و سازندهٔ CMS و همین تزریق timestamp همه به‌صورت سورس Pascal همراه کامپوننت PDFium Delphi منتشر می‌شوند، و باگی با این شکل همان دلیل قانع‌کنندهٔ این کار است: وقتی یک SignedData بازسازی‌شده نود بایت بلندتر از حد بیرون می‌آید، می‌خواهی helperی که برش زده و بند X.690 که غلط خوانده را بخوانی، نه یک stack trace از یک جعبهٔ سیاه