مقاله فنی

تجزیه امضای CMS در Delphi: مرزهای DER و قوس‌های OID

PDF Library for Delphi (PDFlibPas) گواهی‌های داخل امضای PDF را با یک پیمایش خالص DER روی CMS SignedData ذخیره‌شده در /Contents بیرون می‌کشد، بدون اینکه پای CryptoAPI در میان باشد. از v3.539.10 هر خواندن تودرتو با عنصر والدش کران‌دار می‌شود، padding صفر بعد از CMS در طولی که خود CMS اعلام می‌کند بریده می‌شود، و object identifierها زیرشناسهٔ اول ترکیبی‌شان را در base-128 کدگذاری می‌کنند. قاعدهٔ کران‌ها و fix مربوط به OID هر دو کدی را جایگزین کردند که بدون دادن خطا جواب غلط تولید می‌کرد، و قاعدهٔ padding باعث می‌شود خوانندهٔ سخت‌گیرتر امضاهای واقعی را رد نکند

سمت خواندن بیشتر از ظاهرش اهمیت دارد. ابزارهای long-term validation باید قبل از آنکه بتوانند داده‌های ابطال را بگیرند، گواهی امضاکننده و issuerهایش را از یک امضای موجود بیرون بکشند، یک گزارش audit باید بگوید چه کسی امضا کرده، و build ای از Lazarus روی Linux هیچ تابع پیام Windows ای برای تکیه کردن ندارد. parser در چنین جایگاهی به‌ندرت روی ورودی بد crash می‌کند. حالت خرابی که واقعاً دردناک است چیزهایی مثل شمارندهٔ گواهی‌ای که بایت‌های متعلق به همسایه را هم حساب می‌کند، تطبیق امضاکننده با فیلد اشتباه، یا OID ای است که بی‌سروصدا به OID دیگری تبدیل می‌شود. پایپ‌لاین امضایی که روی چنین پایه‌ای ساخته شده باشد، با اعتمادبه‌نفس کامل چرت‌ومرتب گزارش می‌کند

بیرون کشیدن گواهی‌های امضاکننده از یک PDF امضاشده

پنج متد TPDFlib سمت خواندن را پوشش می‌دهند و همهٔ آن‌ها InputFile, Password, FieldName می‌گیرند: هر فراخوانی فایل را فقط‌خواندنی باز می‌کند، جواب می‌دهد و دوباره می‌بنددش. GetSignatureEmbeddedCertificateCount و GetSignatureEmbeddedCertificateDER مجموعهٔ گواهی‌ها را به ترتیب انکودینگ فهرست می‌کنند، GetSignatureSignerCertificateDER گواهی‌ای را برمی‌گرداند که یک SignerInfo معین را تولید کرده، و GetSignatureCertificateChainLength / GetSignatureCertificateChainDER از همان امضاکننده به‌سمت دورترین issuri که خود امضا حمل می‌کند راه می‌روند. ایندکس‌ها از صفر شروع می‌شوند. نتایج را در AnsiString نگه دار، و این دقیقاً دلیل این است که کتابخانه آن‌ها را همین‌طور برمی‌گرداند: یک blob از 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 یک تشخیص نیست: فیلد غایب، رمز عبور اشتباه، blob ای که DER نیست، و SignedData ای که به‌سادگی مجموعهٔ اختیاری گواهی‌ها را حذف کرده، همه به‌شکل 0 یا رشتهٔ خالی برمی‌گردند، پس نام فیلد را کنار عدد لاگ کن. و زنجیره‌ای که پیش از رسیدن به یک گواهی خودصادره تمام می‌شود هم خطا نیست. chain builder فقط از گواهی‌های embed شده در امضا استفاده می‌کند، پس issuerهای باقی‌مانده باید از طریق آدرس‌هایی که GetCertificateIssuerURLs گزارش می‌کند گرفته شوند

چه بخشی از /Contents واقعاً خود CMS است؟

فقط پیشوندی که SEQUENCE بیرونی اعلام می‌کند به CMS تعلق دارد و PLTrimCMSPadding همه‌چیز بعد از آن را می‌برد. امضاکننده رشتهٔ hex رزروِ /Contents را قبل از اینکه CMS وجود داشته باشد کنار می‌گذارد، چون /ByteRange توصیف‌شده در ISO 32000-1 §12.8.1 باید اول ثابت شود، پس جایگاه سخاوتمندانه اندازه می‌گیرد و دنبالهٔ استفاده‌نشده صفر است. PLTrimCMSPadding اولین TLV را می‌خواند، tag برابر $30 را الزامی می‌داند و بایت‌ها را تا انتهای آن عنصر برمی‌گرداند؛ هر چیزی که با یک SEQUENCE خوش‌ساخت شروع نشود خالی برمی‌گردد. همین سطح بیرونی تنها جایی است که بایت‌های دنباله در آن قانونی‌اند، و این تمایز برای بخش بعد مهم است: قاعدهٔ سخت‌گیرانهٔ «عنصر باید کل buffer را مصرف کند» هر امضای دنیای واقعی را رد می‌کرد، در حالی که قاعدهٔ شل در هر عمقی به فیلدهای تودرتو اجازه می‌دهد بایت‌هایی را بخوانند که مال آن‌ها نیست

PLTrimCMSPadding در PDFlibPas اولین TLV رشتهٔ hex رزرو /Contents را می‌خواند، tag برابر 30$ را الزامی می‌داند و padding صفر را در طولی که SEQUENCE بیرونی اعلام می‌کند می‌برد، و وقتی buffer با یک SEQUENCE خوش‌ساخت شروع نشود نتیجهٔ خالی برمی‌گرداند
بایت‌های دنباله فقط در سطح بیرونی قانونی‌اند، جایی که جایگاه رزرو باید برای /ByteRange ثابت بماند؛ خوانده‌های عمیق‌تر به‌جایش قاعدهٔ کران‌دار با والد را می‌گیرند

چرا خوانندهٔ DER به offset انتهای والد نیاز دارد؟

یک عنصر تودرتو فقط وقتی معتبر است که داخل والدش تمام شود، و چک کردن با انتهای buffer این را اثبات نمی‌کند. DERReadTLV سطح‌پایین در PDFlibASN1 هر عنصر را با کل رشته کران‌دار می‌کند، که برای بیرونی‌ترین شیء چک درستی است و برای هر چیز پایین‌تر از آن چک اشتباه. یک SignerInfo را تصور کن که issuerAndSerialNumber اش 40 بایت اعلام می‌کند ولی Name مربوط به issuer داخلش 60 تا ادعا می‌کند. همهٔ بایت‌ها هنوز داخل buffer هستند، پس خواننده‌ای که با buffer کران‌دار می‌شود Name را قبول می‌کند، شمارهٔ سریال را از الگوریتم digest بعدی می‌خواند و بعد آن جفت را با گواهی‌های embed شده مقایسه می‌کند. تا پیش از v3.539.10 پیمایشگر CMS دقیقاً همین‌طور می‌خواند. fix یک wrapper کوچک است که موقعیت انتهای والد را به هر خواندن می‌رساند

PDFlibPas هر خواندن تودرتوی DER را با عنصر والدش کران‌دار می‌کند: یک issuer Name با 60 بایت داخل issuerAndSerialNumber ای 40 بایتی توسط DERReadTLV قدیمیِ کران‌دار با buffer قبول می‌شود که بعد شمارهٔ سریال را از digestAlgorithm می‌خواند، در حالی که ReadTLVWithin هر عنصری را که از ParentEnd بگذرد رد می‌کند
داخل buffer یعنی memory safety، داخل والد یعنی درستی؛ 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، wrapper مربوط به [0] EXPLICIT، فیلدهای SignedData تا signerInfos، SignerIdentifier در هر دو شکل issuerAndSerialNumber و [0] subjectKeyIdentifier اش (RFC 5652 §5.3)، و فیلدهای tbsCertificate که هنگام تطبیق امضاکننده از هر گواهی embed شده خوانده می‌شوند عبور می‌دهد. داخل مجموعهٔ گواهی‌ها و مجموعهٔ signerInfos، عنصری که از انتهای مجموعه عبور کند حلقه را متوقف می‌کند: PLExtractCMSCertificates گواهی‌هایی را که تا آن لحظه قبول کرده برمی‌گرداند و هرگز بایت‌های crls یا signerInfos بعدی را به آخرین گواهی نمی‌چسباند. تطبیق issuer و سریال هم به هر دو نیمه نیاز دارد، چون شمارهٔ سریال فقط داخل یک issuer یکتاست

چرا 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، cast به بایت 55 را نگه می‌دارد و 55 به‌شکل 1.15 دیکود می‌شود، پس شناسه بی‌سروصدا شاخهٔ دیگری از درخت را نام می‌برد. مقادیر 128 تا 255 جور دیگری خراب می‌شوند: یک بایت با بیت ادامهٔ روشن تولید می‌کنند که قوس بعدی را می‌بلعد. بیشتر شناسه‌های PKI (1.2.840...، 2.5.29...، 0.4.0...) هرگز به مرز نمی‌رسند، برای همین این باگ زنده ماند؛ قوس‌های joint-iso-itu-t از 2.48 به بالا می‌رسند. DER_OID هم به انکودر attributeهای امضاشده سرویس می‌دهد هم به تطبیق‌دهندهٔ DERFindExtensionByOID و چک content-type مربوط به 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.
DER_OID در PDFlibPas دو قوس اول OID را به‌شکل 40 * arc1 + arc2 ترکیب و مجموع را در یک UInt64 در base-128 کدگذاری می‌کند، پس 2.999.3 می‌شود 06 03 88 37 03، در حالی که cast قدیمی 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 را بدون wrap حمل می‌کند و buffer موقت ده‌بایتی ده گروه 7 بیتی که یک مقدار 64 بیتی لازم دارد را جا می‌دهد. بردارهای تستی که ارزش نگه داشتن دارند همان‌هایی هستند که دو طرف مرزند: 2.47 باید یک بایت بماند و 2.48 باید دو بایت شود

پیمایشگر CMS سمت خواندن چه چیزهایی را تضمین می‌کند؟

PDFlibCMSRead ساختار را تضمین می‌کند و هیچ چیز دیگر را: بایت‌هایی را برمی‌گرداند که RFC 5652 می‌گوید باید آنجا باشند و هیچ امضا یا digest یا دورهٔ اعتباری را تأیید نمی‌کند. پیمایشگر فقط DER قبول می‌کند، پس DERReadTLV طول‌های نامعین و شماره‌های tag چندبایتی را رد می‌کند و CMS ای که با BER انکود شده از یک امضاکنندهٔ غیرمنطبق به‌جای حدس نیمه‌کاره صفر گواهی گزارش می‌کند. گواهی‌های attribute و بقیهٔ گزینه‌های CertificateChoices رد می‌شوند چون هیچ چیز در پایین‌دست نمی‌تواند از آن‌ها استفاده کند. تأیید رمزنگاشتی پیش کدی می‌ماند که مالک آن است، که با چک‌های پوشش بایت توصیف‌شده در امضای PAdES و اعتبارسنجی ByteRange در Delphi شروع می‌شود و با دسته‌بندی اینکه بعد از امضای PDF چه چیزهایی عوض شده ادامه پیدا می‌کند

درس کلی‌تر به هر فرمت باینری می‌رسد: «داخل buffer» یک خاصیت memory-safety است، «داخل والد» یک خاصیت درستی، و parser به هر دو نیاز دارد. همان نگاه به طول‌های خصمانه در سخت‌سازی parser PDF نوشته‌شده با Pascal در برابر فایل‌های مخرب هم جریان دارد. APIهای استخراج گواهی و ساخت زنجیره و long-term validation که اینجا بحث شد همراه losLab PDF Library for Delphi عرضه می‌شوند، برای Delphi و C++Builder و Lazarus