مقاله فنی

تحلیل تغییرات PDF بعد از امضا با PDFium در Delphi

برای اینکه بفهمید در یک PDF بعد از امضا چه عوض شده، PDFium Component برای Delphi و Lazarus یعنی TPdf.AnalyzeSignatureRevisions را دارد، یک تحلیل‌گر تغییر revisionهای بعد از امضا که هر revision افزایشی را از بایت‌های فایل اصلی بازسازی می‌کند، هر تغییر آبجکتی بعدی را با قواعد DocMDP و FieldMDP همان امضا نمره می‌دهد، و تعریف‌های آبجکت سایه را به‌عنوان یک ریسک جداگانه گزارش می‌کند. موقعیتی که هدفش است برای هر کسی که قرارداد دستش می‌گیرد آشناست: یک فرم certified می‌رود، با دو ذخیرهٔ افزایشی دیگر برمی‌گردد، و هر امضایی همچنان verify می‌شود. این انتظار می‌رود، چون یک امضا فقط بایت‌های revision خودش را پوشش می‌دهد. سؤال واقعی این است که آیا آن ذخیره‌های بعدی مجاز بودند، و تیک سبز روی امضا به این جواب نمی‌دهد

چرا API امضای PDFium نمی‌تواند تغییرهای بعد از امضا را نشان دهد؟

API امضای PDFium نمی‌تواند تغییرهای بعد از امضا را نشان دهد چون فقط دیکشنری امضا را می‌خواند: /Contents و /ByteRange و /SubFilter و مقدار مجوز DocMDP. PDFium هیچ گراف revision افزایشی ندارد، پارامترهای transform مربوط به FieldMDP را پارس نمی‌کند، و diff سطح-آبجکتی بین revisionها نمی‌دهد، پس تحلیل‌گر داخل FPdfPades.pas مستقیم روی بایت‌های خام کار می‌کند. این یک نتیجهٔ عملی دارد که باید طراحی‌تان را دورش بچینید. TPdf.AnalyzeSignatureRevisions بایت‌هایی را می‌خواند که موقع لود سند نگه داشته شده، هرگز کپی‌ای که SaveAs تولید کرده را نه، چون فایل بازنویسی‌شده دقیقاً همان ساختار revisionی را که باید تحلیل شود گم کرده. اگر سند از یک مبدأ progressiveی آمده باشد که دانلودش تمام نشده، گزارش SourceStatus = pvssIncomplete و Status = prasIndeterminate برمی‌گرداند به‌جای تحلیل یک فایل ناقص

بازسازی مرزهای revision از startxref و xref stream و /Prev

تحلیل‌گر مرزهای revision را با دنبال کردن هر startxref به عقب از طریق جدول‌های xref کلاسیک، cross-reference streamها، مدخل‌های hybrid-reference از نوع /XRefStm و زنجیرهٔ /Prev بازسازی می‌کند، همان‌طور که برای incremental updateها در ISO 32000-1 §7.5.6 و §7.5.8 تعریف شده. طول پوشیده‌شدهٔ هر امضا انتهای span دوم ByteRange آن است، و تحلیل‌گر آن طول را به revisionی نگاشت می‌کند که سکشن xrefاش داخلش می‌افتد. وقتی هیچ revisionی مچ نشود، امضا prrCoveredRevisionNotFound می‌گیرد و وضعیت Indeterminate. بعد وضعیت هر آبجکت تا revision پوشیده‌شده بازپخش می‌شود، و هر مدخل xref بعدی با آن وضعیت مقایسه می‌شود. این مهم‌تر از چیزی است که به نظر می‌رسد: بعضی writerها جدول xref کامل را روی هر ذخیرهٔ افزایشی دوباره می‌نویسند، و مدخلی که هنوز به همان آبجکت تغییرنکرده اشاره می‌کند به‌جای گزارش شدن به‌عنوان تغییر skip می‌شود. بدون این مقایسه، یک پر کردن فرم کاملاً قانونی در صدها تغییر جعلی غرق می‌شد

AnalyzeSignatureRevisions چطور در Delphi revisionهای افزایشی را از بایت‌های خام PDF بازسازی می‌کند: ByteRange امضا داخل revision پوشیده‌شده تمام می‌شود، زنجیرهٔ Prev مربوط به xref به عقب از هر ذخیرهٔ بعدی می‌گذرد، وضعیت آبجکت تا revision پوشیده‌شده بازپخش می‌شود، و مدخل‌های بازگویی‌شدهٔ تغییرنکرده به‌جای گزارش شدن به‌عنوان تغییر skip می‌شوند
یک امضا فقط بایت‌های revision خودش را پوشش می‌دهد، پس تحلیل‌گر span دوم ByteRange را به یک revision نگاشت می‌کند و هر مدخل xref بعدی را با وضعیت بازپخش‌شدهٔ آبجکت می‌سنجد

تعریف‌های سایه حالتی هستند که بیشترین توجه را می‌طلبند. بدنهٔ آبجکتی که داخل بازهٔ بایتی یک revision بعدی ظاهر می‌شود ولی توسط xref آن revision ارجاع نمی‌شود برای یک viewer معمولی نامرئی است، و دقیقاً همان نوع staging است که shadow attackها به آن تکیه دارند: محتوای مخفی قبل یا بعد از امضا کاشته می‌شود و بعداً با فلیپ کردن یک ارجاع فعال می‌شود. AnalyzePadesSignatureRevisionsBytes چنین آبجکتی را به‌عنوان یک تغییر غیر-مجاز با IsAuthoritative = False ثبت می‌کند، فارغ از سطح مجوز به آن prdSuspicious می‌دهد، و prrUnreferencedObjectDefinition را به مجموعهٔ ریسک‌ها اضافه می‌کند. دو ریسک مرتبط ترفندهای ساختاری دیگر را پوشش می‌دهند: prrDuplicateObjectDefinition وقتی فعال می‌شود که یک سکشن xref همان آبجکت را بیش از یک‌بار لیست کند، و prrSignatureObjectRedefined وقتی که یک revision بعدی یک آبجکت امضای موجود را بازتعریف کند

یک تعریف آبجکت سایه داخل بازهٔ بایتی یک revision بعدی PDF: بدنهٔ آبجکت وجود دارد ولی هیچ مدخل xrefی به آن ارجاع نمی‌دهد، پس viewerها هرگز نشانش نمی‌دهند، و AnalyzeSignatureRevisions در PDFium Component آن را غیر-مجاز ثبت می‌کند، به آن prdSuspicious می‌دهد و prrUnreferencedObjectDefinition را کنار ریسک‌های تکراری و امضای بازتعریف‌شده بالا می‌برد
محتوای مخفی قبل یا بعد از امضا کاشته می‌شود و بعداً با فلیپ کردن یک ارجاع فعال می‌شود، و دلیلش همین است که یک بدنهٔ بدون ارجاع فارغ از سطح مجوز DocMDP مشکوک نمره می‌گیرد
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

DocMDP و FieldMDP به‌ازای هر امضا چطور اعمال می‌شوند؟

DocMDP و FieldMDP جداگانه به‌ازای هر امضا، در revision پوشیده‌شدهٔ خود آن امضا اعمال می‌شوند، پس یک امضای certification و یک امضای approval بعدی در همان فایل می‌توانند دربارهٔ یک ویرایش واحد به احکام متفاوت برسند. هر آبجکت بعدی اول از روی مدخل‌های /Type و /Subtype و /FT و نقشی که در گراف‌های صفحه و فرم و annotation و DSS بازی می‌کند به یک TPadesRevisionChangeKind طبقه‌بندی می‌شود. هر چه /JavaScript یا /JS یا /Launch یا /OpenAction یا /AA یا /RichMedia یا /EmbeddedFile حمل کند prckActiveContent می‌شود. بعد تصمیم از ISO 32000-1 §12.8.2.2 پیروی می‌کند: با P=1 هر چیزی جز داده‌های cross-reference و مواد راستی‌آزمایی ممنوع است؛ P=2 پر کردن فرم و امضاهای بیشتر را اجازه می‌دهد ولی تغییرهای annotation را رد می‌کند؛ P=3 annotationها را هم اجازه می‌دهد. محتوای صفحه و ساختار سند و متادیتا و محتوای فعال و آبجکت‌های حذف‌شده زیر هر سطح DocMDP ممنوع‌اند، و وقتی امضا اصلاً DocMDP ندارد prdSuspicious می‌گیرند، چون یک امضای approval رسماً چیزی را ممنوع نمی‌کند ولی خواننده دیگر نمی‌بیند چه چیزی امضا شده

FieldMDP، از ISO 32000-1 §12.8.2.4، تصمیم مربوط به فیلدهای فرم را باز هم تنگ‌تر می‌کند. pfmaAll هر فیلد را قفل می‌کند، pfmaInclude فقط فیلدهای لیست‌شده را قفل می‌کند، و pfmaExclude همه‌چیز جز فیلدهای لیست‌شده را قفل می‌کند. برای اعمال Include یا Exclude، تحلیل‌گر هر فیلد تغییرکرده را از طریق زنجیرهٔ /Parent به نام کاملش resolve می‌کند و با مچ دقیق با فهرست قفل مقایسه می‌کند، پس نام فیلدهای terminal را لیست کنید و منتظر نباشید نام والد بچه‌هایش را پوشش دهد. وقتی نامی resolve نشود یا transform از actionی استفاده کند که پارسر نمی‌شناسد، تغییر prdIndeterminate می‌شود و prrFieldMdpUnresolved بالا می‌رود. تصمیم‌های به-ازای-هر-تغییر بعد worst-first جمع می‌شوند، با Suspicious بالاتر از Disallowed و Disallowed بالاتر از Indeterminate و Indeterminate بالاتر از Allowed، پس یک آبجکت سایه از هر تعداد آپدیت فیلد مشروع سنگین‌تر است

پایپ‌لاین نمره‌دهی که AnalyzeSignatureRevisions به‌ازای هر تغییر بعد از امضا در Delphi اعمال می‌کند: یک TPadesRevisionChangeKind از مدخل‌های Type و Subtype، یک تصمیم DocMDP در revision پوشیده‌شده از P=1 تا P=3، یک چک قفل FieldMDP روی نام‌های کامل فیلد، و یک جمع‌بندی worst-first از prdSuspicious تا prdAllowed
یک آبجکت سایه از هر تعداد آپدیت فیلد مشروع سنگین‌تر است چون Suspicious بالاتر از Disallowed و Indeterminate و Allowed می‌نشیند، در حالی که بعضی ریسک‌ها کنار وضعیت ثبت می‌شوند بدون اینکه آن را پایین بکشند

چرا بعضی تغییرها به‌جای امن به‌عنوان Indeterminate برمی‌گردند؟

تغییرها هر وقت که تحلیل‌گر نتواند اثبات کند تغییر مجاز است Indeterminate برمی‌گردند، چون در چک امضا یک ناشناخته هرگز نباید مجاز گزارش شود. یک حالت رایج دقیق هندل می‌شود: long-term validation یک /DSS اضافه می‌کند و catalog را بازنویسی می‌کند، که وگرنه زیر P=1 یک تغییر ساختاری حساب می‌شد. تحلیل‌گر /DSS و /Extensions را از دیکشنری‌های catalog قدیم و جدید می‌کند و بقیه را مقایسه می‌کند؛ وقتی چیز دیگری فرق ندارد، بازنویسی به‌عنوان یک آپدیت مواد راستی‌آزمایی گرفته می‌شود و مجاز است، پس augmentation مربوط به B-LT و B-LTA یک امضای certification را نمی‌شکند. بقیهٔ شکاف‌ها عامدانه باز گذاشته شده‌اند. مدخل‌های Type-2 در یک cross-reference stream داخل object streamهای فشرده اشاره می‌کنند، و تحلیل‌گر داخل این مرز امنیتی object streamها را باز نمی‌کند، پس آن تغییرها به‌صورت prckCompressedObject با prrCompressedObjectUnresolved ظاهر می‌شوند، زیر P=1 ممنوع و در غیر این صورت Indeterminate. سقف‌های سختِ 1024 revision و 1000000 شمارهٔ آبجکت و 2000000 تغییر گزارش‌شده prrResourceLimitExceeded می‌دهند، و زنجیرهٔ xref شکسته prrMalformedRevisionChain؛ هر دو به Indeterminate ختم می‌شوند، هرگز به پاس

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // بعضی ریسک‌ها بدون عوض کردن Status ثبت می‌شوند، پس اول آن‌ها را آزمایش کنید
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

ترتیب در آن گیت عامدانه است. prrDuplicateObjectDefinition بدون پایین کشیدن Status به‌تنهایی به مجموعهٔ ریسک‌ها اضافه می‌شود، و یک transform مربوط به FieldMDP که پارس نشود فقط وقتی روی وضعیت اثر می‌گذارد که یک فیلد فرم واقعاً عوض شده باشد، پس گیتی که فقط به Status نگاه می‌کند می‌تواند شواهدی را که گزارش از قبل حمل می‌کند از دست بدهد. این را هم به خاطر داشته باشید که گزارش چه چیزی ادعا نمی‌کند. TPadesRevisionAnalysisReport هیچ چیزی دربارهٔ معتبر بودن رمزنگارانهٔ امضای CMS یا زنجیره شدن گواهی امضاکننده به ریشه‌ای که شما اعتماد می‌کنید نمی‌گوید. تحلیل revision به سؤال اینکه بعد از امضا چه اتفاقی افتاد جواب می‌دهد، و جای آن کنار اعتبارسنجی ساختاری و اعتماد است، نه به‌جای آن‌ها

نوشتن seed valueها و قفل‌های MDP موقع امضا

همان قواعد را می‌شود موقع امضا نوشت، از طریق TPadesSignatureFieldOptions که عضو FieldOptions هر دو TPadesSignOptions و TPadesRemoteSignOptions است. PDFium می‌تواند widget بسازد ولی نمی‌تواند /SV یا /Lock یا یک transform مربوط به FieldMDP یا DocMDP یا دیکشنری /Perms مربوط به catalog را بنویسد، پس writer افزایشی PAdES خودِ کامپوننت این آبجکت‌ها را داخل همان آپدیت xref امضا تولید می‌کند. FieldName نام فیلد ریشه را ست می‌کند، RequiredSeedValues به بیت‌های /Ff دیکشنری seed-value توصیف‌شده در ISO 32000-1 §12.7.4.5 تبدیل می‌شود، Reasons و LegalAttestations و AcceptableCertificates محدود می‌کنند که یک امضاکنندهٔ بعدی چه می‌تواند انتخاب کند، LockAction همراه LockFields یک /SigFieldLock غیرمستقیم می‌نویسد، و CertificationPermission از 1 تا 3 امضا را به یک امضای certification تبدیل می‌کند. هر دو transform مربوط به DocMDP و FieldMDP داخل یک آرایهٔ /Reference روی مقدار امضا می‌روند، هر کدام با /Data که به catalog اشاره می‌کند

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // فقط پر کردن فرم و امضا
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // فقط این فیلدها را قفل کن
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

چند جزئیات را اگر دستی پیاده‌اش کنید راحت اشتباه می‌گیرید. /Perms /DocMDP مربوط به catalog باید به دیکشنری مقدار امضا ارجاع دهد نه annotation مربوط به widget، و writer برای همین دلیل مقدار امضا را به‌عنوان آبجکت غیرمستقیم خودش نگه می‌دارد. یک دیکشنری /Perms موجود ممکن است از قبل حقوق استفادهٔ /UR3 داشته باشد، پس writer آن را کپی می‌کند و /DocMDP را داخلش insert می‌کند به‌جای جایگزینی، مطابق دیکشنری مجوزها در ISO 32000-1 §12.8.4. سندی که از قبل /DocMDP دارد یک امضای certification دوم را با EPadesCrypto رد می‌کند، و گزینه‌های ناسازگار هم همین‌طور: یک قفل Include یا Exclude بدون نام فیلد، یک قفل All با فهرست فیلد، یک اظهار حقوقی روی امضای غیر-certification، یا یک نقطه در نام فیلد ریشه. امضای ریموت یک قاعدهٔ دیگر هم اضافه می‌کند، چون موقع اجرای PreparePadesRemoteSignature گواهی امضاکننده ناشناخته است: ست کردن CertificateRequired آنجا یک فهرست صریح AcceptableCertificates می‌خواهد، در حالی که امضای محلی می‌تواند به گواهی امضاکنندهٔ resolve‌شده برگردد

تحلیل revision جعبه‌ابزار امضا را کامل می‌کند نه اینکه بخشی از آن را جایگزین کند. با بازرسی امضاهای دیجیتال PDF و سطوح PAdES با PDFium Component شروع کنید تا دیکشنری و سطح baseline را بخوانید، چرا validatorها امضاهای PAdES را رد می‌کنند را برای شکست‌های ساختاری که قبل از هر سؤال revision می‌آیند ببینید، و حکم را داخل یک ممیزی ریسک امنیتی PDF بزرگ‌تر کنار چک‌های JavaScript و فایل تعبیه‌شده جا بدهید. TPdf.AnalyzeSignatureRevisions و TPadesSignatureFieldOptions و writer افزایشی PAdES نشان‌داده‌شده همراه PDFium Component برای Delphi و C++Builder و Lazarus ارسال می‌شوند