السؤال الذي يكسر 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.