مقال تقني

بناء منضدة عمل Compliance والتوقيع في Delphi باستخدام PDFlibPas

السؤال الذي يكسر signing pipelines الضعيفة نادرًا ما يكون cryptographic. يسأل auditor: "تقرير preflight يقول إن دفعة الفواتير هذه متوافقة مع PDF/A، هل جرى التحقق من ذلك قبل تطبيق التوقيع أم بعده؟" عندما يعمل validation والتوقيع كأداتين منفصلتين مع remediation pass بينهما، توجد ثلاث revisions على الأقل من الملف، ولا يصف التقرير إلا واحدة منها. يشحن PDFlibPas، losLab PDF Developer Library لـ Delphi وC++Builder، preflight وPAdES signing خلف facade class واحدة، مما يجعل بناء workbench يملك جوابًا قابلًا للإثبات لذلك السؤال أمرًا عمليًا.

تمشي هذه المقالة عبر نمط workbench من البداية إلى النهاية: preflight على bytes نفسها التي ستوقّع، وتوقيع مطبق عبر SignProcess API، وread-back audit يؤكد أن ByteRange يغطي الملف حقًا. كل call معروض هنا موجود في المكتبة اليوم، وكذلك كل trap.

ثلاث revisions من مستند واحد، وكيف تنفتح الفجوة

يمس workflow compliance-then-sign الملف ثلاث مرات على الأقل. يصل الأصل من upstream. تحمل remediation pass الملف، وتفعل compliance mode، وتحفظ revision مصححة. ثم تضيف signing pass توقيعًا كـ incremental update. كل عملية save من هذه تغير bytes، لذلك لا يكون preflight report ذا معنى إلا إذا قال أي revision يصفه. أرخص طريقة لتثبيت ذلك هي SHA-256 hash للملف يسجل بجانب كل preflight run وكل توقيع.

سلوك واحد في المكتبة يجعل هذا التثبيت أكثر صرامة مما قد تتوقع: إصلاحات compliance المطلوبة عبر SetPDFAMode أو SetPDFUAMode تطبق أثناء save، لا في لحظة استدعائها. auto-repairs مثل فرض annotation print flags أو تعيين PDF/UA tab order تصل إلى output file فقط. تشغيل checker على المستند الذي "أصلحته" للتو في الذاكرة لا يثبت شيئًا عن bytes التي توشك على توقيعها؛ أعد دائمًا تشغيل preflight على الملف المحفوظ.

Preflight من disk، والصفر الذي يعني شيئين

نقطة دخول preflight المسطحة هي CheckFileCompliance(FileName, Password, ComplianceTest, Options)، حيث يختار الاختبار 1 PDF/A ‏(ISO 19005) ويختار الاختبار 2 PDF/UA ‏(ISO 14289). تفتح الملف عبر streaming reader في المكتبة، بلا حاجة إلى LoadFromFile أولًا، وتعيد handle لقائمة string واحدة بكل finding:

var
  PDF: TPDFlib;
  ListID, I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0);  // 1 = PDF/A
    if ListID = 0 then
    begin
      if PDF.LastErrorCode <> 0 then
        raise Exception.Create('Preflight could not read the file')
      else
        Writeln('No PDF/A findings');
    end
    else
    begin
      for I := 0 to PDF.GetStringListCount(ListID) - 1 do
        Writeln(PDF.GetStringListItem(ListID, I));
      PDF.ReleaseStringList(ListID);
    end;
  finally
    PDF.Free;
  end;
end;

الفخ موجود في return value. الصفر يعني "لا توجد findings"، لكنه يعني أيضًا "تعذر فتح الملف"، فالتنفيذ يعيد 0 كلما انتهت result list فارغة، بما في ذلك read failure. workbench يعامل 0 كضوء أخضر سيوافق على ملف locked بواسطة عملية أخرى. اقرن الاستدعاء مع LastErrorCode كما في الأعلى. لاحظ أيضًا أن checker يفتح الملف في deny-write share mode؛ فإذا كانت remediation step لا تزال تمسك writer handle، يفشل preflight لسبب لا علاقة له بالامتثال.

للمراجعة البشرية، يرسم CreatePreflightReport النتائج نفسها كتقرير قابل للقراءة، ويقارن ComparePreflightReports تشغيلتين، وهي طريقة ملائمة لإثبات أن remediation أزالت findings بلا إدخال findings جديدة.

توقيع revision التي فُحصت باستخدام SignProcess

بعد أن تجتاز revision المحفوظة preflight ويصبح hash الخاص بها مسجلًا، وقّع ذلك الملف نفسه. SignProcess API هو builder: افتح process handle، اضبطه، commit، ثم اقرأ result code.

ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
  raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached');  // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2);                      // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192);              // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

يستحق سطران من configuration الانتباه. SetSignProcessCustomSubFilter مع ETSI.CAdES.detached يختار توقيع PAdES كما هو profiled في ETSI EN 319 142-1، بدل عائلة adbe.pkcs7.detached legacy. وSetSignProcessReserveContentsBytes يضيف padding إلى placeholder الخاص بـ /Contents: إذا كنت تنوي إضافة signature timestamp لاحقًا، فيجب أن يلائم CMS الموسع المساحة المحجوزة الآن، لأن placeholder لا يمكن أن يكبر بعد ذلك بلا إعادة توقيع.

GetSignProcessResult يعيد coded outcomes: 1 يعني success، و4 يعني wrong PDF password، و7 wrong certificate password، و9 PFX بلا private key، و11 failure أثناء تطبيق التوقيع. سجل code بدل boolean، فحصة كبيرة من signing support cases هي credential mix-ups لا تميزها إلا هذه القيم.

Read-back: تدقيق الملف الذي أنتجته للتو

ينبغي ألا يثق workbench أبدًا بمسار الكتابة الخاص به. audit class TPDFlibSignDoc يعيد فتح signed output ويعرض entries الخاصة بـ signature dictionary مباشرة:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Capture the size before Open: the audit object holds a share lock on the file
  FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
  SourceSize := FS.Size;
  FS.Free;
  Doc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
    Doc.GetSignatureFieldNames(Names);
    for I := 0 to Names.Count - 1 do
      if Doc.GetSignatureValueObjNum(Names[I]) > 0 then  // > 0 means the field is signed
      begin
        RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
        GapStart   := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
        TailStart  := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
        TailLen    := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
        if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
          Writeln(Names[I], ': signature covers the file to EOF')
        else
          Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
      end;
    Doc.Close;
  finally
    Names.Free;
    Doc.Free;
  end;
end;

تتطابق ValueKey arguments مع dictionary entries: 0 يعيد CMS الخام من /Contents، و2 و3 اسمي /Filter و/SubFilter، و11 إلى 14 أرقام ByteRange الأربعة. تنتقل القيم النصية عبر GetSignatureTextValueByName، حيث key 0 هو وقت التوقيع المزعوم، وkey 5 يميز Sig عاديًا من DocTimeStamp.

التقاط file-size في الأعلى ليس زينة. يمسك TPDFlibSignDoc.Open الملف بقفل share مقيد طوال عمره، لذلك يجب أن يقرأ أي شيء يحتاج raw bytes، مثل hashing للـ signed range أو إعادة حساب CMS digest، الملف قبل Open. demo SigningWorkbench في المكتبة نفسها يقرأ الملف بالكامل إلى الذاكرة أولًا لهذا السبب تحديدًا.

حساب ByteRange الذي يثبت التغطية

ملف single-signature صحي يملك ByteRange بالشكل [0 a b c]: تبدأ التغطية من offset 0، وتتخطى hex /Contents placeholder بين a وb، ثم تستأنف عبر byte b+c. عندما يساوي b+c حجم الملف، يغطي التوقيع كل شيء حتى end of file. وعندما لا يساويه، أضاف شخص incremental update بعد التوقيع. هذا مشروع بموجب ISO 32000-1 §12.8، فملء forms لاحقًا، أو توقيع ثان، أو DSS dictionary، كلها تصل بهذه الطريقة، لكنه بالضبط fact يجب أن يسجله audit trail مقدمًا بدل اكتشافه أثناء نزاع.

انتبه إلى integer width أثناء الفحص. flat API GetSignProcessByteRange يعيد Integer 32-bit، بينما القيم الأساسية Int64؛ في الملفات بعد 2 GB يقتطع flat accessor القيمة. استخدم class-layer TPDFlibSigner.GetByteRange، الذي يعيد Int64، أو حلل القيم من GetSignatureValueByName كما تفعل شيفرة audit أعلاه.

ما الذي يبقى مسؤوليتك

كن واضحًا بشأن الحدود. flat TPDFlib API لا يملك signature verification wrapper إطلاقًا؛ verification cryptographic موجودة في class layer كـ TPDFlibSignatureVerifier، حيث تجيب VerifySignature بـ valid أو invalid أو unknown. كذلك لا يوجد HTTP client مدمج لـ RFC 3161 timestamp authorities؛ تحسب المكتبة hash لإرساله وتعيد تضمين CMS المعزز، لكن network round trip يخص شيفرتك. خطط للاثنين في تصميم workbench من البداية؛ كلاهما سهل التغليف، وكلاهما مزعج جدًا إذا اكتشفت غيابه في final sprint.

الأسئلة الشائعة

هل تؤدي إضافة توقيع إلى كسر PDF/A compliance؟ لا بذاتها. يصل التوقيع كـ incremental update، وISO 19005-2 وما بعده يسمح صراحة بالمستندات الموقعة. لكن signature appearance يخضع لقواعد أي page content أخرى، مثل embedded fonts وعدم وجود device-dependent color، لذلك ينبغي أن تكون البوابة الأخيرة في workbench تشغيل preflight مرة أخرى على signed output.

لماذا ينجح الملف هنا ويفشل في validator خارجي؟ validators تنفذ rule sets متداخلة لكنها ليست متطابقة. عامل CheckFileCompliance كبوابة سريعة داخل pipeline، وتحقق من release candidates بأداة مستقلة مثل veraPDF؛ وعندما يختلف الاثنان عادة يسمي finding النصي clause الذي يجب قراءته.

هل يمكنني التوقيع وإضافة timestamp في pass واحدة؟ لا، يكتب baseline signature أولًا، ثم تعزز timestamp process منفصلة CMS داخل مساحة /Contents المحجوزة. لهذا تهم reserve-bytes call في مثال التوقيع؛ اضبطها لحجم timestamp token الذي تتوقعه.

إلى أين تذهب بعد ذلك

لطبقات timestamp وlong-term validation التي تبنى فوق workbench هذا، راجع شرح PAdES signing and validation. ويغطي دليل PDF/A وPDF/UA preflight نصف preflight بعمق أكبر.

توثيق API الكامل وtrial downloads موجودان في صفحة منتج PDFlibPas.