เวิร์กเบนช์ที่ร้อยการตรวจสอบความสอดคล้องเข้ากับการลงลายเซ็นดิจิทัลต้องประสานงานสี่ขั้นตอนตามลำดับนี้ และต้องผูกทุกขั้นตอนไว้กับชุดไบต์เดียวกันตลอดทาง ขั้นแรกคือรัน preflight ของ PDF/A หรือ PDF/UA ขั้นที่สองคือแก้ไขตามที่ผลตรวจระบุแล้วบันทึกเป็นรีวิชันที่แก้แล้ว ขั้นที่สามคือลงลายเซ็นบนรีวิชันนั้นพอดี จากนั้นจึงอ่านไฟล์ที่เซ็นแล้วกลับเข้ามาและยืนยันว่าลายเซ็นครอบคลุมไฟล์นั้นจริง ลำดับนี้ไม่ใช่เรื่องผิวเผิน ถ้าข้ามขั้นอ่านกลับ เท่ากับคุณกำลังเชื่อเส้นทางการเขียนไฟล์ของตัวเอง และถ้าปล่อยให้ preflight ทำงานกับรีวิชันผิดตัว รายงานความสอดคล้องของคุณก็จะบรรยายไฟล์ที่คุณไม่เคยส่งมอบ
จุดที่ไปป์ไลน์ซึ่งเขียนขึ้นเองส่วนใหญ่พลาดคือรอยต่อระหว่างการตรวจสอบกับการลงลายเซ็น ถ้าแยกสองงานนี้เป็นเครื่องมือคนละตัวโดยมีขั้นแก้ไขคั่นกลาง ไฟล์จะเกิดเป็นรีวิชันที่ต่างกันอย่างน้อยสามชุด แต่ละชุดมีไบต์ของตัวเอง รายงาน preflight ที่คุณยื่นให้ผู้ตรวจสอบบรรยายรีวิชันหนึ่ง ส่วนลายเซ็นตรึงอีกรีวิชันหนึ่ง ไม่มีอะไรในไฟล์ระบุว่าทั้งสองเป็นรีวิชันเดียวกัน และบ่อยครั้งก็ไม่ใช่ PDF Library for Delphi ซึ่งเป็น losLab PDF Developer Library สำหรับ Delphi และ C++Builder วาง preflight กับการลงลายเซ็นแบบ PAdES ไว้หลังคลาส facade เดียว ลำดับงานทั้งหมดจึงอยู่ในโปรเซสเดียวที่ไม่มีวันหลงว่ากำลังพูดถึงไบต์ชุดไหน ทุกการเรียกที่ยกมาด้านล่างมีอยู่จริงในไลบรารีวันนี้ และกับดักทุกข้อที่ระบุไว้ข้างกันก็มีอยู่จริงเช่นกัน
สามรีวิชันของเอกสารฉบับเดียว และช่องว่างเปิดขึ้นได้อย่างไร
ลองนับจำนวนครั้งที่บันทึกไฟล์ ต้นฉบับเดินทางมาจากต้นน้ำ ขั้นแก้ไขโหลดไฟล์นั้นขึ้นมา เปิดโหมดความสอดคล้อง แล้วเขียนรีวิชันที่แก้แล้วออกไป ขั้นลงลายเซ็นผนวกลายเซ็นเข้าไปในรูป incremental update ซึ่งเป็นการเขียนครั้งที่สาม สามครั้งของการบันทึก สามรูปแบบไบต์ และรายงาน preflight ไม่มีความหมายใดเลยหากไม่ระบุว่าครอบคลุมรีวิชันใดในสามชุดนั้น ค่า SHA-256 ของไฟล์ที่บันทึกไว้ข้างการรัน preflight ทุกครั้งและลายเซ็นทุกใบ คือหลักยึดราคาถูกที่ทำให้คุณพิสูจน์ได้ว่ารีวิชันที่ตรวจคือรีวิชันที่เซ็น
ยังมีพฤติกรรมของไลบรารีอีกข้อที่บีบให้ต้องมีวินัยมากขึ้นไปอีก การแก้ไขเพื่อความสอดคล้องที่สั่งผ่าน SetPDFAMode หรือ SetPDFUAMode จะไม่มีผลทันทีตอนที่คุณเรียก แต่จะถูกนำไปใช้ระหว่างการบันทึก การซ่อมอัตโนมัติอย่างการบังคับแฟล็กพิมพ์ของ annotation หรือการกำหนดลำดับ tab ตาม PDF/UA จะไปปรากฏในไฟล์ผลลัพธ์เท่านั้น ไม่ปรากฏที่อื่น ดังนั้นการตรวจเอกสารที่คุณเพิ่ง "แก้" ในหน่วยความจำจึงไม่บอกอะไรเลยเกี่ยวกับไบต์ที่กำลังจะส่งไปให้ผู้เซ็น บันทึกก่อน แล้วค่อย preflight ไฟล์ที่บันทึกแล้ว สถานะในหน่วยความจำเป็นเพียงฉบับร่าง มีเพียงไฟล์บนดิสก์เท่านั้นที่เป็นของจริง
Preflight จากดิสก์ และเลขศูนย์ที่มีความหมายสองอย่าง
จุดเข้าใช้งาน preflight แบบ flat คือ CheckFileCompliance(FileName, Password, ComplianceTest, Options) โดยค่า test 1 เลือก PDF/A (ISO 19005) และค่า test 2 เลือก PDF/UA (ISO 14289) ฟังก์ชันนี้เปิดไฟล์ผ่าน streaming reader ของไลบรารี จึงไม่จำเป็นต้องเรียก LoadFromFile ก่อน และคืนค่าเป็น handle ของ string list ที่บรรจุผลตรวจหนึ่งรายการต่อหนึ่งเอนทรี:
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;
กับดักอยู่ที่ค่าที่คืนกลับมา และเป็นชนิดที่ผ่านการทดสอบเส้นทางปกติได้ทุกครั้ง ศูนย์หมายถึง "ไม่พบข้อบกพร่อง" และศูนย์ยังหมายถึง "เปิดไฟล์ไม่ได้" ด้วย เพราะการทำงานภายในคืนค่า 0 ทุกครั้งที่ลิสต์ผลลัพธ์กลับมาว่างเปล่า รวมถึงกรณีที่อ่านไฟล์ล้มเหลว เวิร์กเบนช์ที่ตีความ 0 ว่าเป็นไฟเขียวจะอนุมัติไฟล์ที่โปรเซสอื่นล็อกค้างไว้อย่างหน้าตาเฉย การจับคู่การเรียกนี้เข้ากับ LastErrorCode ดังตัวอย่างข้างต้นคือสิ่งที่แยกสองกรณีนี้ออกจากกัน ตัวตรวจยังเปิดไฟล์ด้วย share mode แบบ deny-write ด้วย ดังนั้นถ้าขั้นแก้ไขของคุณยังถือ writer handle ค้างอยู่ preflight จะล้มเหลวด้วยเหตุผลที่ไม่เกี่ยวกับความสอดคล้องเลยแม้แต่น้อย แต่เกี่ยวกับสตรีมที่คุณลืมคืนไปเต็ม ๆ
เมื่อผู้ที่ต้องอ่านผลตรวจเป็นคนไม่ใช่ไปป์ไลน์ CreatePreflightReport จะเรนเดอร์ผลเหล่านั้นออกมาเป็นรายงานที่อ่านเข้าใจได้ ส่วน ComparePreflightReports เปรียบเทียบผลการรันสองครั้ง ซึ่งเป็นวิธีเรียบร้อยที่จะแสดงว่าการแก้ไขเคลียร์ข้อบกพร่องเดิมได้โดยไม่แอบสร้างข้อใหม่ขึ้นมา
ลงลายเซ็นรีวิชันที่ตรวจแล้วด้วย SignProcess
เมื่อรีวิชันที่บันทึกไว้ผ่าน preflight และมีค่าแฮชบันทึกไว้เรียบร้อยแล้ว ให้ลงลายเซ็นไฟล์นั้นเท่านั้น ไม่ใช่ไฟล์อื่น API ของ SignProcess อ่านดูคล้ายรูปแบบ builder คือเปิด handle ของโปรเซส ตั้งค่าทีละบรรทัด คอมมิต แล้วอ่านรหัสผลลัพธ์กลับมา
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); // เผื่อที่ไว้ให้ timestamp ในภายหลัง
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
มีสองบรรทัดในลำดับนั้นที่มีน้ำหนักมากกว่าที่ตาเห็น SetSignProcessCustomSubFilter พร้อมค่า ETSI.CAdES.detached เลือกลายเซ็นแบบ PAdES ตามโปรไฟล์ใน ETSI EN 319 142-1 แทนตระกูล adbe.pkcs7.detached แบบเดิม ซึ่งเป็นเส้นแบ่งระหว่างลายเซ็นที่ validator ฝั่งยุโรปยอมรับกับลายเซ็นที่มันตีตราว่ามีปัญหา ส่วน SetSignProcessReserveContentsBytes เติมช่องว่างให้ placeholder ของ /Contents และขนาดที่คุณเลือกตรงนี้คือการตัดสินใจเรื่องอนาคต หากวันหนึ่งจะมี signature timestamp ตามมา CMS ที่ขยายใหญ่ขึ้นต้องพอดีกับพื้นที่ที่คุณจองไว้ตอนนี้ เพราะ placeholder ขยายทีหลังไม่ได้หากไม่เซ็นใหม่ทั้งฉบับ จองเผื่อมากไปก็เสียพื้นที่ไปไม่กี่กิโลไบต์ แต่จองคับเกินไปแล้ว ขั้นตอน timestamp จะล้มเหลวในอีกหลายเดือนข้างหน้าด้วยอาการ overflow ที่คุณจะลำบากในการโยงกลับมาถึงบรรทัดเดียวบรรทัดนี้
GetSignProcessResult ตอบกลับเป็นรหัส ไม่ใช่ค่า boolean และรหัสเหล่านี้ควรค่าแก่การเก็บไว้ 1 คือสำเร็จ 4 คือรหัสผ่าน PDF ผิด 7 คือรหัสผ่านใบรับรองผิด 9 คือ PFX ที่ไม่มี private key และ 11 คือความล้มเหลวขณะกำลังลงลายเซ็น ถ้ายุบทั้งหมดนี้เหลือเพียง true/false คุณก็โยนข้อมูลชิ้นเดียวที่แยกเคสสนับสนุนแบบรหัสผ่านผิดออกจากเคสคีย์ที่ไม่มีส่วน private ทิ้งไป จงบันทึกค่าจำนวนเต็มนั้นลง log
อ่านกลับ: ตรวจสอบไฟล์ที่คุณเพิ่งสร้างขึ้นมาเอง
ไม่มีเวิร์กเบนช์ใดควรเชื่อเส้นทางที่เขียนไฟล์ซึ่งตัวมันกำลังจะรับรอง คลาสตรวจสอบ TPDFlibSignDoc เปิดไฟล์ผลลัพธ์ที่เซ็นแล้วขึ้นมาใหม่ และอ่านเอนทรีของ signature dictionary ตรงจากดิสก์:
var
Doc: TPDFlibSignDoc;
Names: TStringList;
FS: TFileStream;
I: Integer;
SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
// เก็บขนาดไฟล์ก่อนเรียก Open: อ็อบเจกต์ตรวจสอบจะถือ share lock บนไฟล์ไว้
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 หมายถึงฟิลด์นั้นถูกเซ็นแล้ว
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 จับคู่กับเอนทรีใน dictionary คีย์ 0 คืนค่า CMS ดิบจาก /Contents คีย์ 2 และ 3 คืนชื่อ /Filter และ /SubFilter ส่วนคีย์ 11 ถึง 14 คืนตัวเลขสี่ตัวของ ByteRange ค่าที่เป็นข้อความจะกลับมาทาง GetSignatureTextValueByName แทน โดยคีย์ 0 คือเวลาลงลายเซ็นที่ประกาศไว้ และคีย์ 5 แยก Sig ธรรมดาออกจาก DocTimeStamp ซึ่งเป็นเรื่องสำคัญเมื่อเอกสารฉบับหนึ่งมีทั้งสองอย่าง
การเก็บขนาดไฟล์ที่หัวตัวอย่างนั้นเป็นโครงสร้างที่รับน้ำหนักจริง ไม่ใช่งานเก็บกวาด TPDFlibSignDoc.Open ถือไฟล์ไว้ใต้ share lock ที่เข้มงวดตลอดอายุของอ็อบเจกต์ ดังนั้นอะไรก็ตามที่ต้องใช้ไบต์ดิบ (การแฮชช่วงที่ถูกเซ็น การคำนวณ CMS digest ใหม่) ต้องอ่านไฟล์ก่อนที่จะเรียก Open เดโม SigningWorkbench ของไลบรารีเองก็อ่านไฟล์ทั้งไฟล์เข้าหน่วยความจำก่อนด้วยเหตุผลนี้พอดี และเวิร์กเบนช์ที่มองข้ามลำดับนี้จะล้มเหลวเป็นครั้งคราว บนเครื่องที่บังเอิญแพ้การชิงจังหวะพอดี
เลขคณิตของ ByteRange ที่พิสูจน์ความครอบคลุม
ไฟล์ลายเซ็นเดียวที่สมบูรณ์ดีจะมี ByteRange รูปแบบ [0 a b c] คือความครอบคลุมเริ่มที่ offset 0 ข้าม placeholder /Contents ที่เป็นเลขฐานสิบหกระหว่าง a กับ b แล้วกลับมาต่อจนถึงไบต์ b+c เมื่อ b+c เท่ากับขนาดไฟล์ ลายเซ็นก็ครอบคลุมทุกอย่างจนถึงท้ายไฟล์ ซึ่งเป็นผลลัพธ์ที่คุณต้องการ เมื่อค่านั้นขาดหายไป แปลว่ามีคนผนวก incremental update ต่อท้ายหลังจากเขียนลายเซ็นไปแล้ว กรณีนี้ถูกต้องตามกฎทุกประการภายใต้ ISO 32000-1§12.8 เพราะการกรอกฟอร์มภายหลัง ลายเซ็นใบที่สอง และ DSS dictionary ล้วนเข้ามาด้วยวิธีนี้พอดี และนี่คือข้อเท็จจริงที่ audit trail ควรบันทึกไว้ตั้งแต่ตอนลงลายเซ็น แทนที่จะมานั่งประกอบขึ้นใหม่ภายใต้แรงกดดันตอนเกิดข้อพิพาท
ระวังความกว้างของจำนวนเต็มขณะทำเลขคณิตชุดนี้ GetSignProcessByteRange ของ flat API คืนค่าเป็น Integer ขนาด 32 บิต แต่ค่าจริงเบื้องหลังเป็น Int64 ดังนั้นกับไฟล์ที่ใหญ่เกิน 2 GB ตัวเข้าถึงแบบ flat จะตัดค่าทิ้งอย่างเงียบเชียบ ให้หันไปใช้ TPDFlibSigner.GetByteRange ในชั้นคลาสซึ่งคืนค่าเป็น Int64 หรือไม่ก็แยกค่าออกมาจาก GetSignatureValueByName แบบเดียวกับโค้ดตรวจสอบข้างต้น
สิ่งที่ไลบรารีปล่อยให้คุณจัดการเอง
มีขอบเขตสองข้อที่ควรเรียนรู้ตั้งแต่ตอนออกแบบ ดีกว่าไปรู้เอาในสปรินต์สุดท้าย flat API ของ TPDFlib ไม่มีตัวห่อหุ้มสำหรับการตรวจสอบลายเซ็นเลยแม้แต่น้อย การตรวจสอบเชิงวิทยาการรหัสลับอยู่ลึกลงไปอีกชั้นหนึ่ง คืออยู่ใน TPDFlibSignatureVerifier ซึ่ง VerifySignature ของมันตอบว่า valid, invalid หรือ unknown นอกจากนี้ยังไม่มี HTTP client ในตัวสำหรับ timestamp authority ตาม RFC 3161 ไลบรารีคำนวณค่าแฮชที่จะส่งออกไปและฝัง CMS ที่ขยายแล้วกลับเข้าไปเมื่อได้ token กลับมา แต่การรับส่งผ่านเครือข่ายไปยัง TSA เป็นส่วนที่คุณต้องเขียนเอง ทั้งสองอย่างห่อหุ้มได้ไม่ยาก แต่ไม่น่าอภิรมย์เอาเสียเลยหากมาพบว่ายังไม่มีในสัปดาห์ก่อนปล่อยรุ่น จึงควรออกแบบรวมไว้ตั้งแต่ร่างแรก
มีคำถามเรื่องความสอดคล้องอยู่ข้อหนึ่งที่ควรตอบให้ชัด เพราะมันชี้ว่าด่านสุดท้ายควรวางไว้ตรงไหน การเพิ่มลายเซ็นทำให้ PDF/A เสียหรือไม่ คำตอบคือไม่เสียโดยตัวมันเอง ลายเซ็นเข้ามาในรูป incremental update และ ISO 19005-2 เป็นต้นมาอนุญาตเอกสารที่ลงลายเซ็นแล้วไว้อย่างชัดเจน จุดที่ต้องระวังคือหน้าตาของลายเซ็น ซึ่งอยู่ใต้กฎเดียวกับเนื้อหาหน้าอื่น ๆ รวมถึงการฝังฟอนต์และการห้ามใช้สีที่ขึ้นกับอุปกรณ์ ด่านสุดท้ายของเวิร์กเบนช์จึงเป็นการรัน preflight อีกรอบหนึ่ง คราวนี้กับไฟล์ผลลัพธ์ที่เซ็นแล้ว ให้มอง CheckFileCompliance เป็นการตรวจอย่างรวดเร็วภายในไปป์ไลน์ และยังคงตรวจ release candidate ด้วยเครื่องมืออิสระอย่าง veraPDF อยู่ดี เพราะ validator แต่ละตัวใช้ชุดกฎที่ทับซ้อนกันแต่ไม่เหมือนกันเสียทีเดียว เมื่อสองตัวให้ผลไม่ตรงกัน ข้อความผลตรวจมักระบุข้อกำหนดที่ควรไปตามอ่านไว้ให้แล้ว
มีประเด็นเรื่องลำดับอยู่ข้อหนึ่งที่ตกผลึกจากทั้งหมดนี้ การลงลายเซ็นกับการประทับเวลาไม่ใช่งานรอบเดียว ลายเซ็น baseline ถูกเขียนก่อน จากนั้นโปรเซส timestamp ที่แยกต่างหากจึงขยาย CMS ภายในพื้นที่ /Contents ที่จองไว้ ซึ่งเป็นเหตุผลว่าทำไมบรรทัด reserve-bytes ก่อนหน้านี้จึงมีน้ำหนักมากขนาดนั้น สำหรับชั้น timestamp และการตรวจสอบระยะยาวที่ต่อยอดจากเวิร์กเบนช์นี้ คู่มือการลงลายเซ็นและตรวจสอบแบบ PAdES พาลายเซ็นจาก baseline ไปจนถึง B-LT ส่วนฝั่ง preflight เจาะลึกไว้ใน คู่มือ preflight สำหรับ PDF/A และ PDF/UA เอกสาร API ฉบับเต็มและไฟล์ทดลองใช้อยู่ที่ หน้าผลิตภัณฑ์ PDF Library for Delphi