مقاله فنی

پیچیدن امضای PDF: شکاف‌های ByteRange و امضای دوم

HotPDF، کامپوننت PDF برای Delphi، حالا پیچیدن امضا را رد می‌کند: از v2.759.0 هم VerifyLoadedSignatureEx و هم اعتبارسنج دسته‌ای لازم دارند شکاف بین دو سگمنت /ByteRange دقیقاً رشتهٔ hex مربوط به /Contents باشد، شامل delimiterها، و v2.761.0 مقدار AddLoadedSignedSignatureField را اضافه می‌کند تا یک امضای دوم بتواند به یک PDF از قبل امضاشده به‌عنوان یک revision افزایشی تمیز چسبانده شود. این دو تغییر با هم مال یک داستان‌اند، چون یک امضای دوم درست دقیقاً همان چیدمانی است که اعتبارسنج سخت‌گیرتر انتظار دارد

وضعیتی که مشکل را آشکار کرد معمولی است. یک قرارداد توسط فروشنده امضا می‌شود، بعد به تأییدکننده‌ای می‌رود که باید countersign کند بدون اینکه امضای اول را به هم بریزد. revision دوم بعد از اولی اضافه می‌شود، /ByteRange خودش کل فایل بزرگ‌شده را می‌پوشاند، و هر دو امضا باید verify شوند. رسیدن به این با دست یعنی خودت یک بخش افزایشی بنویسی، و فیکسری که دقیقاً همین کار را می‌کرد در کمال تعجب یک ساختار کلاسیک signature wrapping از آب درآمد که اعتبارسنج قدیمی با خوشحالی قبولش می‌کرد. اگر تا حالا به API اعتبارسنجی نگاه نکرده‌ای، راهنمای اعتبارسنجی امضاهای دیجیتال PDF با HotPDF مبانی‌ای را پوشش می‌دهد که این مقاله رویشان بنا شده

دقیقاً چه چیزی جایگاهش در شکاف ByteRange است؟

شکاف باید مقدار کامل /Contents را داشته باشد و هیچ چیز دیگر: ISO 32000-1 §12.8.3.3 می‌گوید رشتهٔ هگزادسیمال، با delimiterهای < و > اش، دقیقاً در فضای بین دو بازهٔ بایتی جا می‌شود، و ISO 32000-2 §12.8.1 همین قاعده را جلو می‌برد. جدول 252 و اسناد PAdES فقط می‌گویند digest مقدار Contents را مستثنا می‌کند، که راحت می‌شود خواندش به‌شکل مستثنا کردن فقط ارقام hex. انتشارهای قبلی HotPDF همین‌طور می‌خواندندش: PreparePDFForSigning و آماده‌سازی CMS استریمی هم براکت‌های زاویه را هش می‌کردند، با کامنتی در سورس که اصرار داشت براکت‌ها باید پوشش داده شوند. اعتبارسنج‌هایی که شکاف را با مقدار امضا مقایسه می‌کنند آن چیدمان را به‌عنوان یک بازهٔ بایتی نامعتبر پرچم می‌زنند، پس v2.759.0 هر دو delimiter را از بازه‌های امضاشده بیرون می‌برد. یک چک مستقل سریع روی هر فایل امضاشده نگاه به دو بایت است: بایت در offset یعنی ByteRange[1] باید < باشد و بایت در offset یعنی ByteRange[2] - 1 باید > باشد

کالبدشکافی یک ByteRange امضای PDF پرشدهٔ درست در HotPDF: بازهٔ اول فایل را از بایت صفر می‌پوشاند، شکاف رشتهٔ hex کامل /Contents را نگه می‌دارد شامل delimiterهای کوچک‌تر-از و بزرگ‌تر-از، بازهٔ دوم trailer را تا انتها پوشش می‌دهد، و دو چک تک‌بایتی در ByteRange[1] و ByteRange[2] - 1 چیدمان را روی هر فایل امضاشده تأیید می‌کنند
از v2.759.0 delimiterها بیرون از بازه‌های امضاشده می‌نشینند، پس digest فقط ارقام را می‌پوشاند و شکاف را می‌شود بایت‌به‌بایت اعتبارسنجی کرد

چرا چک شکاف غیرخالی پیچیدن امضا را از دست می‌دهد؟

یک چک شکافِ غیرخالی فقط اثبات می‌کند چیزی از digest بیرون مانده، نه اینکه چه چیزی، و کل سطح حمله همین است. placeholder مربوط به /Contents با هزاران رقم صفر رزرو می‌شود، در حالی که یک کانتینر CMS واقعی به‌ندرت آن را پُر می‌کند. مهاجم می‌تواند رشتهٔ hex را داخل آن padding صفر با یک > زودتر ببندد، اشیای جدید یا یک revision جعلی را در بقیهٔ فضای رزرو بنویسد و بازه‌های بایتی را دست‌نخورده رها کند. امضای CMS همچنان verify می‌شود چون هر بایت امضاشده بدون تغییر است، بازه‌ها همچنان از 0 شروع و به اندازهٔ فایل ختم می‌شوند، و اعتبارسنج قدیمی HotPDF گزارش می‌کرد svValid با CoversWholeDocument روی True. reader از PDF در همین حین هر چه در آن حفرهٔ امضانشده نشسته را تجزیه می‌کند

HotPDF حالا با شکاف مثل داده‌ای رفتار می‌کند که باید بایت‌به‌بایت اعتبارسنجی شود. اعتبارسنج شکاف را می‌خواند، delimiterها را می‌تراشد، فقط ارقام hex به‌علاوهٔ فاصله‌های خالی PDF (تب، line feed، form feed، carriage return، فاصله) را قبول می‌کند، ارقام را دیکود می‌کند و لازم دارد نتیجه دقیقاً با /Contents دیکشنری امضا برابر باشد. هر چیز دیگری نتیجه را به svInvalidByteRange تنزل می‌دهد. این چک در هر دو مسیر تک‌امضا و ValidateLoadedSignatureBatch اجرا می‌شود، که منطق پوشش خودش را نگه داشته بود و همان fix را لازم داشت. فایل‌هایی که HotPDF قبل از v2.759.0 تولید کرده، که شکافشان فقط ارقام داشت و براکت‌ها درست داخل بازه‌ها نشسته بودند، همچنان verify می‌شوند، پس اسناد آرشیوی ناگهان قرمز نمی‌شوند

چگونه پیچیدن امضا از یک ByteRange PDF با چکِ شل در Delphi سوءاستفاده می‌کند: مهاجم رشتهٔ hex را داخل هزاران رقم صفر رزرو شده زودتر می‌بندد، یک revision جعلی را بدون لمس هیچ بایت پوشش‌داده‌شده‌ای در شکاف امضانشده می‌نویسد، و چک قدیمی HotPDF تا v2.759.0 که شروع کرد شکاف را بایت‌به‌بایت اعتبارسنجی کند گزارش می‌داد svValid با CoversWholeDocument برابر true
شکاف غیرخالی فقط اثبات می‌کند چیزی از digest بیرون مانده، نه اینکه چه چیزی؛ همان حفرهٔ pad شده کل سطح حمله است
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

چطور یک امضای دوم به یک PDF از قبل امضاشده اضافه کنیم؟

فایل امضاشده را با BeginIncrementalUpdate باز کن، AddLoadedSignedSignatureField را صدا بزن، با SaveIncrementalUpdate ذخیره کن، بعد فایل آماده‌شده را با تابع کلاسی THotPDF.SignPDFWithPFX امضا کن. قبل از v2.761.0 دستور مستند یعنی صدازدن THPDFPage.AddSignedSignatureField بعد از BeginIncrementalUpdate نمی‌توانست کار کند، چون CurrentPage در حالت افزایشی nil است و هیچ چیز نمی‌توانست یک placeholder از جنس /V را به فیلدی روی سند بارگذاری‌شده آویزان کند. متد جدید widget را روی صفحهٔ بارگذاری‌شده می‌سازد و همان dictionary placeholder ای که مسیر سند-جدید استفاده می‌کند را زیر /V می‌آویزد، پس هر دو مسیر امضا یک سریالیزاسیون مشترک دارند. برای خود امضای اول، مقالهٔ ساختن امضاهای دیجیتال PAdES در Delphi پایپ‌لاین PFX را قدم‌به‌قدم می‌رود

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // صفحهٔ 0، مستطیل widget در نقطه، 8192 بایت برای CMS رزرو شده
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureField عمداً از خواهر و برادرهایش آرام‌تر است. بقیهٔ سازنده‌های فیلد از جنس AddLoaded* روی AcroForm مقدار /NeedAppearances true می‌گذارند که به viewer می‌گوید appearance فیلدها را دوباره تولید کند؛ روی سند امضاشده آن تولید دوباره می‌تواند محتوای امضاشده را بازنویسی کند، پس متد جدید فلگ را دوباره حذف می‌کند مگر اینکه مبدأ از قبل حملش می‌کرده. /SigFlags مقدار اصلی خودش OR 3 را نگه می‌دارد (SignaturesExist به‌علاوهٔ AppendOnly، ISO 32000-1 جدول 219). لازم هم نیست MarkDirty را روی صفحه صدا بزنی: اضافه کردن به /Annots و /Fields فلگ dirty را به شیء غیرمستقیم مالک منتشر می‌کند، و یک علامت صریح صفحه فقط dictionary صفحهٔ دست‌نخورده را به revision جدید می‌کشاند که تحلیل revision بعد آن را به‌عنوان یک تغییر صفحه گزارش می‌کند. در نهایت، placeholder مقدار /ByteRange را قبل از /Contents می‌نویسد، چون patcher اول sentinel مربوط به /ByteRange را پیدا می‌کند و رو-به-جلو برای رشتهٔ hex متناظر جست‌وجو می‌کند

جریان کار HotPDF در Delphi برای countersign کردن یک PDF از قبل امضاشده: BeginIncrementalUpdate فایل را باز می‌کند، AddLoadedSignedSignatureField widget را می‌سازد و placeholder مربوط به /Contents را رزرو می‌کند، SaveIncrementalUpdate یک revision دوم اضافه می‌کند و SignPDFWithPFX آن را پر می‌کند، به‌طوری که امضای اول معتبر می‌ماند با UnsignedTrailingBytes در حالی که ByteRange جدید کل فایل بزرگ‌شده را می‌پوشاند
یک placeholder به‌ازای هر revision، آماده و وصله‌شده توسط همان سریالیزاسیون در هر دو مسیر امضا؛ همان چیدمان تمیزی که اعتبارسنج سخت‌گیرتر انتظار دارد

وقتی یک امضاکنندهٔ خارجی یا HSM تولید CMS را می‌کند چه چیزهایی عوض می‌شود؟

هیچ چیز در جریان کار عوض نمی‌شود، ولی offsetها حالا همان چیزی را می‌گویند که مشخصات می‌گوید. PreparePDFForSigning دو بازهٔ صفر-مبنا برمی‌گرداند که شکافشان کل رشتهٔ /Contents است، و ContentsHexStart ایندکس یک-مبنای اولین رقم hex در AnsiString است. یک CMS کوتاه‌تر در انتها، قبل از > بسته‌شونده، با 0 pad می‌شود. چون PreparePDFForSigning اولین sentinel وصله‌نشده‌ای که پیدا می‌کند را وصله می‌زند، دقیقاً یک placeholder به‌ازای هر revision آماده کن و وقتی امضاهای قبلی از قبل در فایل هستند InsertSignatureHexAt را با offsetهای برگشتی به InsertSignatureHex مبتنی بر جست‌وجو ترجیح بده

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // هلپر خودت
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // شکاف کل رشتهٔ hex است: '<' بازهٔ 1 را می‌بندد، '>' قبل از بازهٔ 2 می‌آید
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // امضاکنندهٔ CMS خودت، DER به‌شکل hex
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // هلپر خودت
end;

مرزهای چک‌های جدید کجاست؟

چک شکاف یک حفرهٔ مشخص را می‌بندد و نباید بیش از اندازه به آن فروخت. svValid همچنان یعنی یکپارچگی بایتی به‌علاوهٔ یک کلید که با گواهی embed شده جور است؛ اعتماد به آن گواهی تصمیمی جداگانه است. شکاف فقط وقتی اعتبارسنجی می‌شود که اعتبارسنج بایت‌های مبدأ داشته باشد، که VerifyLoadedSignatureEx از فایل بارگذاری‌شده می‌خواند و overloadهای TStream از خودت می‌گیرند. برای امضای اول در یک فایل countersign شده، CoversWholeDocument به‌درستی False است، و اینکه revision اضافه‌شده فقط یک امضا اضافه کرده یا صفحات را هم عوض کرده سؤالی برای تحلیل DocMDP و FieldMDP و revision در HotPDF است. دقت کن که چک PDF MAC پیوست‌شده offsetها را با موقعیت‌های < و > مقایسه می‌کند، پس هم چیدمان قدیمی و هم جدید را قبول می‌کند؛ هر ابزار خودت که offsetهای قبل از v2.759.0 را هاردکد کرده وقتی به یک فایل تازه امضاشده می‌رسد اول شکست می‌خورد

اگر اپلیکیشن Delphi یا C++Builder تو PDFها را امضا می‌کند، countersign می‌کند یا audit می‌کند، امن‌ترین مسیر این است که بگذاری یک کتابخانه همان چیدمان را تولید و verify کند. HotPDF، کامپوننت بومی PDF برای Delphi اعتبارسنجی شکاف سخت‌گیرتر و امضاهای دوم افزایشی و قلاب‌های امضاکنندهٔ خارجی نشان‌داده‌شده در بالا را در یک کامپوننت عرضه می‌کند