บทความเทคนิค

ลายเซ็นดิจิทัล PDF และ PAdES ใน Delphi ด้วย HotPDF

ลายเซ็น PDF ส่วนใหญ่คือเรื่องการจัดการไบต์ให้ลงตัว และก็เป็นจุดที่มันมักผิดพลาด การเข้ารหัสเชิง cryptography ทำงานบนโค้ดที่ผ่านการตรวจสอบมาแล้วนานสองทศวรรษ ส่วนนี้แทบไม่เคยล้มเหลวเลย สิ่งที่ล้มเหลวในการใช้งานจริงกลับเรียบง่ายกว่านั้น: placeholder ที่จองพื้นที่ไว้เล็กเกินไปสำหรับลายเซ็นจริง แฮชที่คำนวณผิดช่วงของไฟล์ หรือการ "save" หลังเซ็นชื่อที่เขียนทับไบต์ที่ลายเซ็นได้ตรึงไว้แล้วอย่างเงียบ ๆ จัดวางไบต์ให้ถูกต้อง แล้วเครื่องหมายถูกสีเขียวก็จะดูแลตัวมันเอง

HotPDF ครอบคลุมการเซ็นชื่อสำหรับ Delphi และ C++Builder ไว้สามระดับ และคุณเลือกระหว่างระดับเหล่านี้ด้วยการตอบคำถามเดียว: private key อยู่ที่ไหน? ไฟล์ PFX บนดิสก์ต้องการแค่การเรียกฟังก์ชันเดียว key ที่ถูกล็อกอยู่ใน HSM หรือบริการเซ็นชื่อระยะไกลต้องการลำดับขั้นตอน reserve-hash-insert เพราะไม่มีไลบรารีใดสามารถเข้าไปในโทเคนแล้วดึงเอา key ออกมาได้ ลายเซ็นที่ต้องเป็นไปตามข้อบังคับของยุโรปต้องการโครงสร้าง PAdES baseline เพิ่มเติมอีกชั้นหนึ่ง หัวข้อด้านล่างจะไล่ตามลำดับขั้นนี้

แผนภาพการตัดสินใจเลือกระหว่างการลงลายเซ็นคำสั่งเดียวด้วย PFX ของ HotPDF, เส้นทาง reserve-hash-insert เมื่อคีย์อยู่ใน HSM หรือบริการระยะไกล และโครงสร้าง baseline ของ PAdES สำหรับการลงลายเซ็นยุโรปที่ถูกควบคุม
เลือกขั้นการลงนามโดยถามว่ากุญแจส่วนตัวอาศัยอยู่ที่ไหน ไฟล์ PFX ที่อ่านได้ยุบการลงนามเหลือการเรียกครั้งเดียว ขณะที่กุญแจที่ถืออยู่ใน token บังคับให้อ้อมที่ระดับไบต์ และกฎระเบียบยุโรปเพิ่มชั้น PAdES เข้ามา

วิธีที่ /ByteRange ตรึงไบต์ที่ถูกเซ็นไว้ให้แน่น

ลายเซ็นต้องอยู่ภายในไฟล์ที่มันเซ็น และมันเซ็นตัวเองไม่ได้ PDF แก้ปัญหาที่ดูเหมือนย้อนแย้งนี้ด้วยการเว้นช่องว่างไว้ ก่อนเซ็นชื่อ ตัวเขียนจะจองรายการ /Contents ขนาดคงที่ที่เต็มไปด้วยเลขศูนย์ และบันทึกอาร์เรย์ /ByteRange สำหรับสองช่วงที่อยู่รอบ ๆ ช่องว่างนั้น: ทุกอย่างก่อนช่องว่าง และทุกอย่างหลังช่องว่าง ตัวเซ็นจะแฮชสองช่วงนั้นแล้วเขียนก้อน CMS ที่ได้ลงในช่องว่างในรูปแบบเลขฐานสิบหก กับดักอยู่ที่คำว่า คงที่ คุณต้องตัดสินใจกำหนดขนาดของช่องว่างนั้นก่อนที่จะรู้ว่าลายเซ็นที่เสร็จสมบูรณ์จะมีขนาดใหญ่แค่ไหน ดังนั้นการจองพื้นที่จึงต้องเป็นการประเมินสูงไว้ก่อนอย่างมั่นใจ แปดกิโลไบต์เพียงพอสบาย ๆ สำหรับลายเซ็น CMS แบบ detached ที่มี certificate chain สั้น ๆ

HotPDF แยกสองกรณีนี้ออกเป็นสองฟังก์ชันเรียก และการสับสนระหว่างสองตัวนี้เป็นข้อผิดพลาดที่พบบ่อยในช่วงแรก ๆ AddSignatureField จะวางฟิลด์เปล่าที่มองเห็นได้ไว้ให้คนมาเซ็นภายหลังใน viewer AddSignedSignatureField จะสร้างฟิลด์และจองช่องว่าง /Contents ไว้ ซึ่งเป็นตัวที่คุณต้องการเมื่อไรก็ตามที่โค้ด แทนที่จะเป็นมนุษย์ เป็นผู้ทำการเซ็นให้เสร็จสมบูรณ์ ถ้าส่งฟิลด์เปล่าให้ตัวเซ็นภายนอก มันก็ไม่มีอะไรให้กรอก

เส้นทางเรียกครั้งเดียว: การเซ็นชื่อจากไฟล์ PFX

เมื่อ certificate และ private key ของมันอยู่ในไฟล์ PFX/PKCS#12 ที่ process ของคุณอ่านได้ pipeline ทั้งหมดก็ลดเหลือแค่ class function เดียว:

if THotPDF.SignPDFWithPFX('invoice-unsigned.pdf', 'invoice-signed.pdf',
    'company-cert.pfx', 'pfx-password') then
  Writeln('Signed: invoice-signed.pdf')
else
  raise Exception.Create('PFX signing failed');

เมื่อสิ่งนี้ล้มเหลว ปัญหามักไม่ได้อยู่ที่ตัว PDF แต่อยู่ที่ตัว PFX เอง HotPDF อ่าน container ที่ป้องกันด้วย PBES2 นั่นคือการคำนวณ key แบบ PBKDF2 บน AES-256-CBC ไฟล์ PFX ที่ export มาจากตัวช่วยสร้าง certificate รุ่นเก่าของ Windows หรือจาก OpenSSL ก่อนเวอร์ชัน 3.0 มักจะถูกห่อด้วย RC2 หรือ 3DES รุ่นเก่าแทน และมันจะ parse ไม่ได้เลย วิธีแก้คือ export container ใหม่อีกครั้งด้วยการป้องกันแบบสมัยใหม่ OpenSSL รุ่นปัจจุบันทำแบบนี้เป็นค่าเริ่มต้นอยู่แล้ว และมันไม่ใช่การแก้โค้ดใด ๆ ดังนั้นเมื่อการเซ็นชื่อล้มเหลวทันทีกับ certificate ที่ "ใช้ได้ทุกที่" ให้ตรวจดูว่า PFX นั้นถูกสร้างขึ้นมาอย่างไร ก่อนที่จะสงสัยโค้ดของตัวเอง

เส้นทาง reserve-hash-insert สำหรับ HSM และโทเคน

เส้นทางเรียกครั้งเดียวตั้งสมมติฐานว่า process ของคุณอ่าน key เป็นไฟล์ได้ แต่นับวันกรณีนี้จะยิ่งเป็นไปไม่ได้มากขึ้น key อยู่ใน HSM บนโทเคน USB หรืออยู่หลัง API ของบริการเซ็นชื่อ และไม่มีทางที่ไลบรารีจะเข้าถึงมันได้โดยตรง HotPDF จัดการเรื่องนี้ด้วยการแบ่งการเซ็นชื่อออกเป็นขั้นตอนระดับไบต์: เขียนเอกสาร placeholder ขอช่วงแฮชจากไลบรารี ส่งอินพุตที่จะแฮชไปให้สิ่งที่ถือ key อยู่ แล้วจึงต่อ CMS ที่ได้กลับเข้าไปในช่องว่างนั้น

HotPDF: ไปป์ไลน์ reserve-hash-insert สี่ขั้นบน placeholder.pdf แสดงรู /Contents ที่สงวนไว้ระหว่างช่วง ByteRange ทั้งสอง และการแลก digest เป็นเลขฐานสิบหก CMS กับ HSM
HotPDF สงวนรูไว้และรายงานช่วง ByteRange ทั้งสองช่วง ผู้ถือกุญแจของคุณลงนามส่วนนั้นจากภายนอก และ CMS ที่คืนมาถูกประสานกลับเข้าไปแบบไบต์ต่อไบต์โดยไม่แตะไบต์ที่ตรึงไว้แม้ไบต์เดียว
var
  Doc: THotPDF;
  Fs: TFileStream;
  PdfBytes, HashInput, SigHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, CStart, CLen: Integer;
begin
  // 1. เขียนเอกสารพร้อมช่องว่าง /Contents ที่จองไว้
  Doc := THotPDF.Create(nil);
  try
    Doc.FileName := 'placeholder.pdf';
    Doc.BeginDoc;
    Doc.CurrentPage.AddSignedSignatureField('Sig1',
      Rect(50, 100, 350, 150), 8192, 'adbe.pkcs7.detached',
      'Contract approval', 'Boston, MA', 'legal@example.com');
    Doc.EndDoc;
  finally
    Doc.Free;
  end;

  // 2. โหลดไบต์ที่บันทึกไว้; offset ที่ได้กลับมานับเริ่มจาก 0
  Fs := TFileStream.Create('placeholder.pdf', fmOpenRead);
  try
    SetLength(PdfBytes, Fs.Size);
    Fs.ReadBuffer(PdfBytes[1], Fs.Size);
  finally
    Fs.Free;
  end;
  THotPDF.PreparePDFForSigning(PdfBytes, R1Start, R1Len, R2Start, R2Len,
    CStart, CLen);

  // 3. แฮชทั้งสองช่วงแล้วเซ็นชื่อจากภายนอก (HSM, โทเคน, บริการ)
  HashInput := Copy(PdfBytes, R1Start + 1, R1Len) +
               Copy(PdfBytes, R2Start + 1, R2Len);
  SigHex := SignWithHsm(HashInput);  // การเชื่อมต่อของคุณเอง: คืนค่า CMS เป็นเลขฐานสิบหก

  // 4. ต่อลายเซ็นเข้าไปในช่องว่างที่จองไว้
  THotPDF.InsertSignatureHex(PdfBytes, SigHex);
  Fs := TFileStream.Create('signed.pdf', fmCreate);
  try
    Fs.WriteBuffer(PdfBytes[1], Length(PdfBytes));
  finally
    Fs.Free;
  end;
end;

มีรายละเอียดสองอย่างในลำดับนี้ที่เป็นสาเหตุของความล้มเหลวแบบไม่สม่ำเสมอส่วนใหญ่ อย่างแรกคือ PreparePDFForSigning ทำงานบนไบต์ของไฟล์ที่เสร็จสมบูรณ์แล้วเท่านั้น placeholder ต้องถูกเขียนและบันทึกให้ครบสมบูรณ์ก่อนที่ offset จะมีความหมายใด ๆ ถ้าคำนวณ offset จาก stream ที่ยังประกอบไม่เสร็จ มันจะไม่ตรงกับไบต์ที่คุณจะแฮชในที่สุด อย่างที่สองคือขนาดการจองพื้นที่ อีกครั้งหนึ่ง 8192 ไบต์ที่คุณขอไว้ต้องรองรับ CMS สุดท้ายให้ได้ และลายเซ็นที่พ่วง certificate ระดับกลางมาด้วย หรือลายเซ็นที่บริการหนึ่งตกแต่งด้วย signed attribute อาจมีขนาดเกินกว่านั้นได้ InsertSignatureHex จะไม่ขยายช่องว่างให้มีที่ว่างเพิ่มขึ้น สัญญาณบ่งชี้คือ pipeline ที่เซ็นชื่อได้ปกติกับ certificate ตัวหนึ่งแต่ล้มเหลวกับตัวถัดไป ทางแก้คือสร้าง placeholder ขึ้นใหม่โดยวัดขนาดการจองพื้นที่จากลายเซ็นจริงที่ผลิตขึ้นโดยตัวเซ็นจริง ไม่ใช่การเดา

PAdES baseline และ timestamp ที่ทำให้ลายเซ็นยังคงใช้งานได้ต่อไป

หากคุณกำลังเซ็นชื่อภายใต้กฎระเบียบของยุโรป มาตรฐานที่เกี่ยวข้องคือ ETSI EN 319 142-1 ซึ่งซ้อนระดับ PAdES baseline ไว้สี่ระดับ B-B คือลายเซ็นแบบธรรมดา B-T เพิ่ม timestamp ที่เชื่อถือได้ซึ่งพิสูจน์ว่าเซ็นเมื่อไร B-LT ฝังเอกสารการตรวจสอบเข้าไปด้วย ทั้ง certificate และข้อมูล revocation ไว้ในเอกสารเพื่อให้ยังตรวจสอบได้อีกหลายปีถัดไป B-LTA ซ้อน timestamp ของเอกสารเป็นระยะ ๆ ไว้ด้านบนอีกชั้น เพื่อให้หลักฐานยังคงอยู่ได้นานกว่าอัลกอริทึมที่มันถูกสร้างขึ้นมาด้วย HotPDF สร้างโครงสร้างฝั่งเอกสารสำหรับแต่ละระดับ:

HotPDF: ระดับ baseline ของ PAdES วางซ้อนจาก B-B ผ่าน B-T และ B-LT ไป B-LTA พร้อมไทม์ไลน์ต่ออายุแสดง document timestamp ตามรอบที่ทำให้ลายเซ็นตรวจสอบได้แม้ผ่านไปหลายสิบปี
แต่ละระดับซ้อนการป้องกันใหม่บนระดับก่อนหน้า B-LTA คอยต่อ document timestamp ซ้ำ ๆ เพื่อให้หลักฐานอายุยืนกว่าอัลกอริทึมที่มันเริ่มสร้างบนอยู่เสมอ
// ฟิลด์ลายเซ็น PAdES baseline (ETSI EN 319 142-1)
Pdf.CurrentPage.AddPAdESSignatureField(
  'ApprovalSig', Rect(50, 100, 350, 150), 'B-B',
  'Contract approval', 'Boston, MA', 'legal@example.com');

// timestamp ของเอกสาร: จองพื้นที่มากขึ้นสำหรับโทเคน TSA และ chain
Pdf.CurrentPage.AddDocumentTimestampSignature('ArchiveTS', 16384);

การจองพื้นที่ 16384 ไบต์สำหรับ timestamp นั้นเป็นความตั้งใจ หน่วยงาน timestamp authority จะคืนโทเคนที่พ่วง certificate chain ของตัวมันเองมาด้วย ดังนั้นตามปกติแล้วมันต้องการพื้นที่มากกว่า 8 KB ที่ลายเซ็นธรรมดาพอใจอยู่แล้ว document timestamp เหล่านี้ยังเป็นกลไกเบื้องหลัง B-LTA ด้วย: การ timestamp เอกสารที่เก็บถาวรซ้ำทุก ๆ สองสามปีด้วยอัลกอริทึมที่ยังทันสมัยอยู่ คือสิ่งที่ทำให้เอกสารที่คุณเซ็นในปี 2026 ยังคงตรวจสอบได้ในปี 2040

ขอกล่าวถึงสตริง reason, location และ contact ที่ทั้งสองฟังก์ชันเรียกรับไว้สักหน่อย: มันเป็นเพียง metadata เพื่อความสะดวกเท่านั้น ไม่มีอะไรมากไปกว่านั้น HotPDF เก็บมันไว้เป็นรายการ dictionary ธรรมดาและวาดมันลงใน appearance ของลายเซ็นที่มองเห็นได้ แต่ไม่มี validator ตัวไหนตรวจสอบมันกับอะไรเลย ให้กรอกข้อมูลเหล่านี้ให้สอดคล้องกันจากข้อมูล workflow ของคุณ เพราะผู้ตรวจสอบก็อ่านมันจริง ๆ แต่อย่าเข้าใจผิดว่ามันคือหลักฐาน การอ้างสิทธิ์เชิง cryptographic ที่แท้จริงอยู่ใน CMS และ certificate chain ของมันทั้งหมด และตัวตรวจสอบจะไม่สนใจข้อความที่มองเห็นได้เลยแม้แต่น้อย

หลังเซ็นชื่อแล้ว ไฟล์ทำได้แค่โตขึ้นเท่านั้น

ทันทีที่ลายเซ็นถูกสร้างขึ้น ไบต์ภายในช่วงของมันจะถูกตรึงไว้ วิธีเดียวที่ถูกต้องตามกฎในการเปลี่ยนไฟล์หลังจากนั้นคือการทำ incremental update ตาม ISO 32000-1 §7.5.6 ซึ่งจะต่อท้าย object ใหม่และที่เปลี่ยนแปลงไปหลังไบต์เดิม แล้วเชื่อมโยงส่วน cross-reference ใหม่กลับไปหามัน เมื่อทำแบบนี้ ลายเซ็นจะยังคงใช้ได้กับ revision ของมัน และ viewer จะรายงานสถานะที่ตรงตามความเป็นจริง: revision ที่เซ็นไว้ยังคงสมบูรณ์ และเอกสารถูกขยายเพิ่มเติมภายหลัง แต่ถ้า re-serialize ไฟล์ทั้งไฟล์ใหม่แทน จะเป็นการเขียนทับช่วงที่ถูกเซ็นไว้ ซึ่งทำลายลายเซ็นแม้ว่าจะไม่มีอะไรเปลี่ยนแปลงที่มองเห็นได้เลยก็ตาม กลไก revision แบบเดียวกันนี้ยังเป็นวิธีที่เอกสารหนึ่งฉบับมีลายเซ็นได้หลายอัน: ลายเซ็นใหม่แต่ละอันจะตกอยู่ใน incremental update ของตัวเอง และช่วงของมันจะครอบคลุมทุกอย่างก่อนหน้า รวมถึงลายเซ็นก่อนหน้าด้วย กลไกแบบ append-only นี้ และเวลาที่ปลอดภัยจะบีบอัดมันได้ อธิบายไว้ในบทความเรื่อง object stream และ incremental update

มีขอบเขตสองอย่างที่ควรจำไว้ในใจขณะออกแบบ โหมด output แบบ PDF/A ของ HotPDF จะปฏิเสธฟิลด์ลายเซ็นไปเลยโดยสิ้นเชิง ดังนั้นความสอดคล้องด้านการเก็บถาวรกับลายเซ็นที่ฝังไว้จึงต้องส่งมอบเป็นไฟล์แยกกัน และการเซ็นชื่อไม่ได้บอกอะไรเกี่ยวกับความลับเลย มันพิสูจน์แค่ว่าใครเป็นผู้สร้างเอกสารและมันไม่ได้ถูกเปลี่ยนแปลงตั้งแต่นั้นมา แต่ใคร ๆ ก็ยังอ่านมันได้อยู่ดี การปกปิดเนื้อหาเป็นงานอีกส่วนหนึ่ง ซึ่งจัดการด้วยการเข้ารหัส AES-256 และนโยบายสิทธิ์การใช้งาน

ไม่ว่าคุณจะสร้างอะไรขึ้นมา ให้ทดสอบมันด้วยเครื่องมืออื่นที่ไม่ใช่โค้ดที่เขียนไฟล์นั้นขึ้นมาเอง เปิดผลลัพธ์ในแผง signature ของ Acrobat แล้วยืนยันสามอย่าง: ลายเซ็นถูกต้อง identity เชื่อมโยงไปถึง root ที่คุณคาดหวังไว้ และแผงรายงานว่าไม่มีการเปลี่ยนแปลงใด ๆ ตั้งแต่เซ็นชื่อ จากนั้นลองสลับไบต์เพียงตัวเดียวภายในช่วงที่ถูกเซ็นของสำเนาที่ใช้แล้วทิ้ง แล้วยืนยันว่าตอนนี้แผงบอกว่าเอกสารถูกเปลี่ยนแปลงแล้ว pipeline การเซ็นชื่อที่คุณไม่เคยเห็นมันปฏิเสธไฟล์ที่ถูกแก้ไขเลยสักครั้ง คือ pipeline ที่การตรวจสอบของมันยังไม่เคยถูกทดสอบจริง ๆ

การเซ็นชื่อทั้งสามระดับมาพร้อมกับHotPDF Delphi Componentสำหรับ Delphi และ C++Builder หน้าผลิตภัณฑ์มีลิงก์ไปยังเอกสารอ้างอิง API ของลายเซ็นแบบครบถ้วน