HotPDF component PDF สำหรับ Delphi ตอนนี้ปฏิเสธ signature wrapping แล้ว: ตั้งแต่ v2.759.0 ทั้ง VerifyLoadedSignatureEx กับตัวตรวจแบบ batch บังคับว่าช่องว่างระหว่างสองช่วงของ /ByteRange ต้องเป็น hex string ของ /Contents พอดีทั้งชุดรวม delimiter ด้วย และ v2.761.0 เพิ่ม AddLoadedSignedSignatureField เข้ามาให้ลายเซ็นที่สองถูกต่อท้าย PDF ที่ลงนามไปแล้วเป็น incremental revision ที่สะอาด สองการเปลี่ยนนี้เป็นคู่กัน เพราะลายเซ็นที่สองที่ถูกต้องคือเลย์เอาต์ที่ตัวตรวจที่เข้มขึ้นคาดหวังพอดี
สถานการณ์ที่เผยปัญหาให้เห็นธรรมดามาก contract ถูกลงนามโดยผู้ขาย แล้วถูกส่งต่อไปหาผู้อนุมัติที่ต้องเซ็นรับรองโดยไม่ทำลายลายเซ็นแรก revision ที่สองถูกต่อท้าย revision แรก /ByteRange ของมันครอบคลุมไฟล์ที่โตขึ้นทั้งไฟล์ และทั้งสองลายเซ็นควร verify ผ่าน ไปถึงจุดนั้นด้วยมือแปลว่าต้องเขียน incremental section เอง และ fixture ทดสอบที่ทำแบบนั้นเป๊ะ ๆ กลับออกมาเป็นโครงสร้าง signature wrapping ตำราเรียนที่ตัวตรวจรุ่นเก่ารับไปยิ้ม ๆ ถ้าคุณยังไม่เคยดู API ฝั่งตรวจสอบ คู่มือตรวจลายเซ็นดิจิทัล PDF ด้วย HotPDFเล่าพื้นฐานที่บทความนี้ต่อยอดไว้แล้ว
อะไรที่ต้องอยู่ในช่องว่าง ByteRange เป๊ะ ๆ
ช่องว่างต้องมีค่า /Contents ครบถ้วนและไม่มีอย่างอื่น: ISO 32000-1 §12.8.3.3 บอกว่า hexadecimal string พร้อม delimiter < กับ > วางพอดีในพื้นที่ระหว่างสองช่วงไบต์ และ ISO 32000-2 §12.8.1 หอบกฎเดียวกันนี้ต่อมา Table 252 กับเอกสาร PAdES พูดแค่ว่า digest ตัดค่า Contents ออก ซึ่งอ่านผิดเป็น "ตัดแค่ตัวเลข hex" ได้ง่าย ๆ รีลีส HotPDF รุ่นก่อนหน้าอ่านแบบนั้นจริง: PreparePDFForSigning กับการเตรียม CMS แบบสตรีม hash วงเล็บมุมเข้าไปด้วย พร้อมคอมเมนต์ในโค้ดยืนกรานว่าวงเล็บต้องถูกครอบ ตัวตรวจที่เทียบช่องว่างกับค่าลายเซ็นติงเลย์เอาต์นี้เป็น invalid byte range v2.759.0 จึงย้าย delimiter ทั้งคู่ออกนอกช่วงที่ถูกลงนาม วิธีเช็คอิสระอย่างรวดเร็วกับไฟล์ที่ลงนามแล้วคือมองสองไบต์: ไบต์ที่ offset ByteRange[1] ต้องเป็น < และไบต์ที่ offset ByteRange[2] - 1 ต้องเป็น >
ทำไมการเช็คแค่ว่าช่องว่างไม่ว่างถึงพลาด signature wrapping
การเช็คแค่ว่าช่องว่างไม่ว่างพิสูจน์ได้แค่ว่ามีบางอย่างถูกเว้นออกจาก digest ไม่ได้พิสูจน์ว่าอะไรถูกเว้น และนั่นคือ attack surface ทั้งหมด placeholder ของ /Contents ถูกสำรองไว้ด้วยเลขศูนย์หลายพันตัว ขณะที่ container CMS จริงแทบไม่เคยเต็มช่อง ผู้โจมตีปิด hex string ให้จบเร็วขึ้นข้างใน padding ศูนย์นั้นด้วย > เขียน object ใหม่หรือ revision ปลอมลงในพื้นที่สำรองที่เหลือ แล้วปล่อย byte ranges ไว้ตามเดิม ลายเซ็น CMS ยัง verify ผ่าน เพราะทุกไบต์ที่ถูกลงนามไม่เปลี่ยน ranges ยังเริ่มที่ 0 และจบที่ขนาดไฟล์ และตัวตรวจ HotPDF รุ่นเก่ารายงาน svValid พร้อม CoversWholeDocument เป็น True ระหว่างนั้น PDF reader ก็ parse อะไรก็ตามที่นั่งอยู่ในรูที่ไม่มีใครลงนามรูนั้น
HotPDF ตอนนี้ถือว่าช่องว่างเป็นข้อมูลที่ต้องตรวจทีละไบต์ ตัวตรวจอ่านช่องว่าง ลอก delimiter ออก รับเฉพาะเลข hex กับ whitespace ของ PDF (แท็บ, line feed, form feed, carriage return, ช่องว่าง) ถอดรหัสตัวเลขแล้วบังคับให้ผลลัพธ์เท่ากับ /Contents ของ dictionary ลายเซ็นเป๊ะ ๆ อย่างอื่นใดถูกลดเกรดเป็น svInvalidByteRange เช็คนี้รันทั้งเส้นทางลายเซ็นเดี่ยวและ ValidateLoadedSignatureBatch ซึ่งมี logic ครอบคลุมของตัวเองและต้องการการแก้แบบเดียวกัน ไฟล์ที่ HotPDF ผลิตก่อน v2.759.0 ซึ่งช่องว่างมีแต่ตัวเลขโดยวงเล็บนั่งอยู่ข้างในช่วงพอดียัง verify ผ่าน เอกสารในคลังจึงไม่เปลี่ยนเป็นสีแดงขึ้นมาเฉย ๆ
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
Status: THPDFSignatureVerifyStatus;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('SignedTwice.pdf');
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
begin
Status := Pdf.VerifyLoadedSignatureEx(I, Info);
case Status of
svValid:
if Info.CoversWholeDocument then
Writeln(Info.FieldName, ': valid, covers the whole file')
else
Writeln(Info.FieldName, ': valid, ',
Info.UnsignedTrailingBytes, ' bytes appended later');
svInvalidByteRange:
Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
else
Writeln(Info.FieldName, ': failed, status ', Ord(Status));
end;
end;
finally
Pdf.Free;
end;
end;
เพิ่มลายเซ็นที่สองให้ PDF ที่ลงนามแล้วอย่างไร
เปิดไฟล์ที่ลงนามแล้วด้วย BeginIncrementalUpdate เรียก AddLoadedSignedSignatureField บันทึกด้วย SaveIncrementalUpdate แล้วเซ็นไฟล์ที่เตรียมไว้ด้วย class function THotPDF.SignPDFWithPFX ก่อน v2.761.0 สูตรในเอกสารที่ให้เรียก THPDFPage.AddSignedSignatureField หลัง BeginIncrementalUpdate ทำให้สำเร็จไม่ได้เลย เพราะ CurrentPage เป็น nil ในโหมด incremental และไม่มีทางแปะ placeholder ของ /V เข้าฟิลด์บนเอกสารที่โหลดไว้ได้ เมธอดใหม่สร้าง widget บนหน้าที่โหลดไว้แล้วแขวน dictionary placeholder ชุดเดียวกับที่เส้นทางเอกสารใหม่ใช้ไว้ใต้ /V สองเส้นทางการลงนามจึงแชร์ serialization เดียวกัน สำหรับลายเซ็นแรกเอง บทความสร้างลายเซ็นดิจิทัล PAdES ใน Delphiเดินตาม pipeline ของ PFX ให้ครบ
var
Pdf: THotPDF;
FieldIndex: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.BeginIncrementalUpdate('Signed.pdf');
// หน้า 0, สี่เหลี่ยม widget เป็นพอยต์ สำรอง CMS ไว้ 8192 ไบต์
FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
'ApproverSignature', 8192);
if FieldIndex < 0 then
raise Exception.Create('Page index out of range');
Pdf.SaveIncrementalUpdate('Prepared.pdf');
finally
Pdf.Free;
end;
if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
'approver.pfx', 'pfx-password') then
raise Exception.Create('Second signature failed');
end;
AddLoadedSignedSignatureField ตั้งใจเงียบกว่าพี่น้องของมัน ตัวสร้างฟิลด์ตระกูล AddLoaded* ตัวอื่นตั้ง /NeedAppearances true บน AcroForm ซึ่งบอก viewer ให้สร้าง appearance ของฟิลด์ใหม่ บนเอกสารที่ลงนามแล้วการสร้างใหม่นั้นอาจเขียนทับเนื้อหาที่ถูกลงนาม เมธอดใหม่จึงถอดธงนี้ออกอีกครั้ง เว้นแต่ต้นทางมีมันอยู่แล้ว /SigFlags คงค่าเดิม OR 3 (SignaturesExist บวก AppendOnly, ISO 32000-1 Table 219) คุณยังไม่ต้องเรียก MarkDirty บนหน้า: การเพิ่มลง /Annots กับ /Fields ส่งธง dirty ไปถึง indirect object เจ้าของแล้ว และการติดป้ายหน้าอย่างชัดเจนจะลาก dictionary ของหน้าที่ไม่เปลี่ยนลง revision ใหม่ ซึ่งการวิเคราะห์ revision จะรายงานกลับมาเป็นการแก้ไขหน้า ปิดท้าย placeholder เขียน /ByteRange ไว้ก่อน /Contents เพราะ patcher หา sentinel /ByteRange เจอก่อนแล้วค่อยค้นไปข้างหน้าเพื่อเจอ hex string ที่จับคู่กัน
อะไรเปลี่ยนเมื่อ external signer หรือ HSM ผลิต CMS ให้
อะไรก็ไม่เปลี่ยนใน workflow แต่ offset ตอนนี้หมายความตามสเปกจริง ๆ PreparePDFForSigning คืนสองช่วงแบบเริ่มที่ศูนย์ที่ช่องว่างของมันคือ string ทั้งชุดของ /Contents และ ContentsHexStart เป็นดัชนีแบบเริ่มที่หนึ่งของเลข hex ตัวแรกใน AnsiString CMS ที่สั้นกว่าถูก padding ด้วย 0 ท้ายช่อง ก่อนถึง > ปิด เพราะ PreparePDFForSigning patch sentinel ตัวแรกที่ยังไม่ถูก patch ที่มันเจอ ให้เตรียม placeholder พอดีหนึ่งตัวต่อหนึ่ง revision และเลือกใช้ InsertSignatureHexAt กับ offset ที่คืนมาแทน InsertSignatureHex ที่อาศัยการค้นหา เมื่อลายเซ็นก่อนหน้ามีอยู่แล้วในไฟล์
var
Bytes, ToSign, CmsHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
Bytes := LoadFileAsAnsiString('Prepared.pdf'); // helper ของคุณ
if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
R2Start, R2Len, HexStart, HexLen) then
raise Exception.Create('No signature placeholder found');
// ช่องว่างคือ hex string ทั้งชุด: '<' ปิดช่วง 1, '>' นำหน้าช่วง 2
Assert(Bytes[R1Start + R1Len + 1] = '<');
Assert(Bytes[R2Start] = '>');
ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
CmsHex := SignDetachedWithHsm(ToSign); // ตัวเซ็น CMS ของคุณ, hex DER
if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
raise Exception.Create('CMS does not fit the reserved space');
SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf'); // helper ของคุณ
end;
ขีดจำกัดของการเช็คใหม่อยู่ตรงไหน
การเช็คช่องว่างปิดรูเฉพาะเจาะจงหนึ่งรู อย่าไปขายมันเกินความจริง svValid ยังหมายถึงความครบถ้วนของไบต์บวกกับ key ที่ตรงกับใบรับรองที่ฝังมา การไว้ใจใบรับรองนั้นเป็นการตัดสินใจแยกต่างหาก ช่องว่างถูกตรวจเฉพาะเมื่อตัวตรวจมีไบต์ต้นฉบับ ซึ่ง VerifyLoadedSignatureEx อ่านจากไฟล์ที่โหลดไว้และ overload ฝั่ง TStream รับมาจากคุณ สำหรับลายเซ็นแรกในไฟล์ที่ถูกเซ็นรับรองซ้ำ CoversWholeDocument เป็น False อย่างถูกต้อง และเรื่องที่ revision ที่ต่อมาเพิ่มแค่ลายเซ็นหรือแก้หน้าด้วย เป็นคำถามของDocMDP, FieldMDP กับการวิเคราะห์ revision ใน HotPDF จดไว้ด้วยว่าการเช็ค PDF MAC ที่แนบมาเทียบ offset กับตำแหน่งของ < กับ > มันจึงรับทั้งเลย์เอาต์เก่าและใหม่ tool ของคุณเองที่ hard-code offset ก่อน v2.759.0 จะล้มเป็นรายแรกเมื่อเจอไฟล์ที่เพิ่งลงนามสด ๆ
ถ้าแอปพลิเคชัน Delphi หรือ C++Builder ของคุณลงนาม เซ็นรับรองซ้ำหรือ audit PDF เส้นทางที่ปลอดภัยที่สุดคือปล่อยให้ library เดียวผลิตและตรวจเลย์เอาต์เดียวกัน HotPDF component PDF native สำหรับ Delphiมาพร้อมการตรวจช่องว่างที่เข้มขึ้น ลายเซ็นที่สองแบบ incremental และ hook สำหรับ external signer ที่โชว์ไว้ข้างบนใน component ตัวเดียว