مقال تقني

تحليل تغييرات PDF بعد التوقيع عبر PDFium في دلفي

لمعرفة ما تغير في PDF بعد توقيعه، يوفر مكوّن PDFium لدلفي وLazarus‏ TPdf.AnalyzeSignatureRevisions، محلل تغييرات مراجعات بعد التوقيع يعيد بناء كل مراجعة تزايدية من بايتات الملف الأصلي، ويقيّم كل تغيير كائني لاحق مقابل قواعد DocMDP وFieldMDP لذلك التوقيع، ويبلغ عن تعريفات كائنات ظلية بوصفها خطرًا منفصلًا. والموقف الذي يستهدفه مألوف لمن يتعامل مع العقود: نموذج معتمد يخرج، ويعود بحفظين تزايدين إضافيين، وما تزال كل تواقيع تتحقق. ذلك متوقع، لأن التوقيع يغطي بايتات مراجعته هو فقط. والسؤال الحقيقي هل سُمح بتلك الحفظات اللاحقة، وعلامة صح خضراء على التوقيع لا تجيب عنه

لماذا لا تستطيع API التوقيع في PDFium إظهار ما تغير بعد التوقيع؟

لا تستطيع API التوقيع في PDFium إظهار التغييرات اللاحقة للتوقيع لأنها لا تقرأ إلا قاموس التوقيع: /Contents و/ByteRange و/SubFilter وقيمة إذن DocMDP. لا يملك PDFium رسم مراجعات تزايدية، ولا يحلل معاملات تحويل FieldMDP، ولا يقدم فرقًا على مستوى الكائن بين المراجعات، فيعمل المحلل في FPdfPades.pas على البايتات الخام مباشرة بدلًا من ذلك. ولهذا نتيجة عملية عليك أن تصمم حولها: يقرأ TPdf.AnalyzeSignatureRevisions البايتات المحفوظة لحظة تحميل المستند، لا نسخة أنتجها SaveAs قط، لأن ملفًا أعيدت كتابته فقد بنية المراجعات ذاتها التي تحلل. وإن جاء المستند من مصدر تقدمي لم يكتمل تنزيله، أعاد التقرير SourceStatus = pvssIncomplete وStatus = prasIndeterminate بدل تحليل ملف مقطوع

إعادة بناء حدود المراجعات من startxref وتدفقات xref و/Prev

يعيد المحلل بناء حدود المراجعات بتتبع كل startxref عائدًا عبر جداول xref الكلاسيكية وتدفقات الإشارات المرجعية ومداخيل /XRefStm الهجينة وسلسلة /Prev، كما عرف للتحديثات التزايدية في ISO 32000-1 §7.5.6 و§7.5.8. والطول المغطى لكل توقيع هو نهاية امتداده الثاني في ByteRange، ويربط المحلل ذلك الطول بالمراجعة التي يقع قسم xref خاصتها داخله. وإن لم تطابق أي مراجعة، نال التوقيع prrCoveredRevisionNotFound وحالة Indeterminate. ثم يعاد تشغيل حالة كل كائن حتى المراجعة المغطاة، وتقارن كل مدخل xref لاحق مع تلك الحالة. وهذا يهم أكثر مما يبدو: بعض الكاتبات تعيد ذكر جدول xref كاملًا عند كل حفظ تزايدي، والمدخل الذي ما يزال يشير إلى الكائن غير المتغير نفسه يتخطى بدل التبليغ عنه بوصفه تعديلًا. وبلا تلك المقارنة كان تعبئة نموذج قانونية تمامًا سترصد مئات التغييرات المزيفة

كيف يعيد AnalyzeSignatureRevisions بناء المراجعات التزايدية من بايتات PDF الخام في دلفي: ينتهي ByteRange للتوقيع داخل المراجعة المغطاة، وتسير سلسلة Prev في xref عائدة عبر كل حفظ لاحق، وتعاد تشغيل حالة الكائنات حتى المراجعة المغطاة، وتتخطى المداخيل المذكورة من جديد غير المتغيرة بدل التبليغ عنها تغييرات
التوقيع يغطي بايتات مراجعته هو فقط، لذا يربط المحلل امتداد ByteRange الثاني بمراجعة ويقيّم كل مدخل xref لاحق مقابل حالة الكائنات المعاد تشغيلها

التعريفات الظلية هي الحالة التي تستحق أكبر انتباه. جسم كائن يظهر داخل مدى بايتات مراجعة لاحقة لكن لا تشير إليه xref تلك المراجعة غير مرئي لعارض عادي، ومع ذلك هو بالضبط صنف التجهيز الذي تعتمد عليه هجمات الظل: محتوى مخفي يزرع قبل التوقيع أو بعده ثم يفعل لاحقًا بقلب مرجع. يسجل AnalyzePadesSignatureRevisionsBytes ذلك الكائن تغييرًا غير موثق بـ IsAuthoritative = False، ويقيّمه prdSuspicious بغض النظر عن مستوى الإذن، ويضيف prrUnreferencedObjectDefinition إلى مجموعة المخاطر. ويغطي خطران مرتبطان حيلًا بنيوية أخرى: يطلق prrDuplicateObjectDefinition حين يسرد قسم xref واحد الكائن نفسه أكثر من مرة، ويطلق prrSignatureObjectRedefined حين تعيد مراجعة لاحقة تعريف كائن توقيع قائم

تعريف كائن ظلي داخل مدى بايتات مراجعة PDF لاحقة: جسم الكائن موجود لكن لا مدخل xref يشير إليه فلا تعرضه العوارض قط، ويسجله AnalyzeSignatureRevisions في مكوّن PDFium غير موثق ويقيّمه 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 كل على حدة لكل توقيع، عند المراجعة المغطاة لذلك التوقيع نفسه، فيستطيع توقيع اعتماد وتوقيع موافقة لاحق في الملف نفسه أن يصلا إلى حكمين مختلفين عن التعديل نفسه. تصنف كل كائنة لاحقة أولًا في TPadesRevisionChangeKind من مداخيل /Type و/Subtype و/FT لديها ومن دورها في رسوم الصفحة والنموذج والتعليق وDSS. وكل شيء يحمل /JavaScript أو /JS أو /Launch أو /OpenAction أو /AA أو /RichMedia أو /EmbeddedFile يصير prckActiveContent. ثم يسير القرار وفق ISO 32000-1 §12.8.2.2: مع P=1 يحرم كل شيء عدا بيانات الإشارات المرجعية ومادة التحقق؛ ويسمح P=2 بتعبئة النموذج وتوقيعات إضافية لكنه يرفض تغييرات التعليقات؛ ويسمح P=3 بالتعليقات أيضًا. محتوى الصفحة وبنية المستند والبيانات الوصفية والمحتوى النشط والكائنات المحذوفة تحرم تحت أي مستوى DocMDP، وتقّيم prdSuspicious حين لا يحمل التوقيع DocMDP إطلاقًا، لأن توقيع الموافقة لا يحرم شيئًا رسميًا لكن القارئ لم يعد يرى ما وقع عليه التوقيع

يضيّق FieldMDP، من ISO 32000-1 §12.8.2.4، قرار حقول النموذج أكثر. يقفل pfmaAll كل حقل، ويقفل pfmaInclude الحقول المسردة فقط، ويقفل pfmaExclude كل شيء عدا الحقول المسردة. ولتطبيق Include أو Exclude، يحسم المحلل كل حقل متغير إلى اسمه المؤهل تمامًا عبر سلسلة /Parent ويقارنه بقائمة القفل تطابقًا تامًا، فسرد أسماء الحقول الطرفية لا أن تتوقع أن اسمًا أبويًا يغطي أولاده. وحين يعجز حسم اسم أو يستخدم التحويل إجراءً لا يعرفه المحلل، يصير التغيير prdIndeterminate ويرفع prrFieldMdpUnresolved. ثم يتجمع قرار كل تغيير الأسوأ أولًا، وSuspicious يرتقي فوق Disallowed، وDisallowed فوق Indeterminate، وIndeterminate فوق Allowed، فيفوق كائن ظلي واحد أي عدد من تحديثات الحقول المشروعة

مسار التقييم الذي يطبقه AnalyzeSignatureRevisions على كل تغيير بعد التوقيع في دلفي:‏ TPadesRevisionChangeKind من مداخيل Type وSubtype، وقرار DocMDP عند المراجعة المغطاة من P=1 إلى P=3، وفحص قفل FieldMDP على أسماء الحقول المؤهلة تمامًا، وتجميع الأسوأ أولًا من prdSuspicious نزولًا إلى prdAllowed
كائن ظلي واحد يفوق أي عدد من تحديثات الحقول المشروعة لأن Suspicious يرتقي فوق Disallowed وIndeterminate وAllowed، بينما تسجل بعض المخاطر بجوار الحالة دون هبوطها

لماذا تعود بعض التغييرات Indeterminate بدل آمنة؟

تعود التغييرات Indeterminate كلما عجز المحلل عن إثبات أن التغيير مسموح، لأنه في فحص التوقيع لا يجوز أبدًا التبليغ عن مجهول بوصفه مسموحًا. حالة شائعة تعالج بدقة بدل ذلك: التحقق طويل الأمد يضيف /DSS ويعيد كتابة الكتالوج، وهو ما كان سيحسب تغييرًا بنيويًا تحت P=1. ينزع المحلل /DSS و/Extensions من قاموسي الكتالوج القديم والجديد ويقارن البقية؛ وحين لا يختلف شيء آخر، تعامل إعادة الكتابة تحديثًا لمادة التحقق ويسمح، فلا تكسر إضافات B-LT وB-LTA توقيع اعتماد. وفجوات أخرى تترك مفتوحة بقصد. مداخيل النوع 2 في تدفق إشارات مرجعية تشير إلى داخل تدفقات كائنات مضغوطة، والمحلل لا ينشر تدفقات الكائنات داخل هذا الحد الأمني، فتصير تلك التغييرات prckCompressedObject مع prrCompressedObjectUnresolved، محرمة تحت P=1 وIndeterminate بخلاف ذلك. وميزانيات صلبة من 1024 مراجعة و1,000,000 رقم كائن و2,000,000 تغيير مبلغ عنها تعطي 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 بذاته، وتحويل FieldMDP الذي لا يمكن تحليله لا يمس الحالة إلا حين يتغير حقل نموذج فعلًا، فبوابة تنظر في Status وحده تستطيع أن تفوت دليلًا يحويه التقرير سلفًا. وتذكر أيضًا ما لا يدعيه التقرير.‏ TPadesRevisionAnalysisReport لا يقول شيئًا عن كون توقيع CMS صحيحًا تشفيريًا أو عن كون شهادة الموقع تتسلسل إلى جذر تثق به. تحليل المراجعات يجيب عن سؤال ما حدث بعد التوقيع، ومكانه بجوار التحقق البنيوي والثقة، لا محلها

كتابة قيم البذر وأقفال MDP وقت التوقيع

تستطيع تأليف القواعد نفسها وقت التوقيع عبر TPadesSignatureFieldOptions، وهو عضو FieldOptions في كل من TPadesSignOptions وTPadesRemoteSignOptions. يستطيع PDFium إنشاء widget لكنه لا يستطيع كتابة /SV أو /Lock أو تحويل FieldMDP أو DocMDP أو قاموس /Perms في الكتالوج، فينتج كاتب PAdES التزايدي الخاص بالمكوّن تلك الكائنات داخل تحديث xref نفسه الخاص بالتوقيع. يضبط FieldName اسم الحقل الجذري، ويصير RequiredSeedValues بتات /Ff لقاموس قيم البذر الموصوف في ISO 32000-1 §12.7.4.5، وتقيد Reasons وLegalAttestations وAcceptableCertificates ما يستطيع الموقع اللاحق اختياره، ويكتب LockAction مع LockFields‏ /SigFieldLock غير مباشرة، ويجعل CertificationPermission من 1 إلى 3 التوقيع توقيع اعتماد. ويلتحم كلا تحويلي DocMDP وFieldMDP في مصفوفة /Reference واحدة على قيمة التوقيع، كلٌ بـ /Data تشير إلى الكتالوج

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 في الكتالوج إلى قاموس قيمة التوقيع، لا إلى تعليق widget، ولهذا يحفظ الكاتب قيمة التوقيع كائنًا غير مباشر خاصًا بها. وقاموس /Perms قائم قد يحمل حقوق استخدام /UR3 سلفًا، فينسخه الكاتب ويقحم /DocMDP بدل استبداله، وفق قاموس الأذونات في ISO 32000-1 §12.8.4. ومستند يحمل /DocMDP سلفًا يرفض توقيع اعتماد ثانيًا بـ EPadesCrypto، وكذلك الخيارات غير المتسقة: قفل Include أو Exclude بلا أسماء حقول، أو قفل All بقائمة حقول، أو إثبات قانوني على توقيع غير اعتمادي، أو نقطة في اسم الحقل الجذري. ويضيف التوقيع البعيد قاعدة أخرى، لأن شهادة التوقيع مجهولة حين يجري PreparePadesRemoteSignature: ضبط CertificateRequired هناك يطالب بقائمة AcceptableCertificates صريحة، بينما يستطيع التوقيع المحلي العودة إلى شهادة الموقع المحسومة

تحليل المراجعات يكمل صندوق أدوات التوقيع لا يستبدل أي جزء منه. ابدأ بـ فحص تواقيع PDF ومستويات PAdES عبر مكوّن PDFium لقراءة القاموس والمستوى الأساسي، وانظر لماذا ترفض المدققات تواقيع PAdES عن الإخفاقات البنيوية التي تسبق أي سؤال مراجعات، وادمج الحكم في تدقيق مخاطر أمان PDF أوسع بجوار فحوص JavaScript والملفات المدمجة. ويأتي TPdf.AnalyzeSignatureRevisions وTPadesSignatureFieldOptions وكاتب PAdES التزايدي المعروض هنا مع مكوّن PDFium لدلفي وC++Builder وLazarus