Technical Article

การลงลายเซ็น PDF ด้วย PAdES B-B ใน Delphi โดยใช้ PDFium

PDFium Component จะลงลายเซ็นดิจิทัลมาตรฐาน PAdES B-B บนเอกสาร PDF ผ่านทางเมธอด SignPades ของตนเอง: ขั้นตอนการทำงานประกอบด้วยการลงเอกสาร, คำนวณแฮชเหนือช่วงไบต์ที่ลงลายเซ็น, สร้างโครงสร้าง CAdES CMS และทำการแนบลายเซ็นแบบบันทึกเพิ่มพูน ระบบคริปโตกราฟีเบื้องหลังนี้รองรับเฉพาะบนระบบปฏิบัติการ Windows เท่านั้น ดังนั้นต้องตรวจสอบความพร้อมใช้งานด้วยคุณสมบัติ PadesCryptoAvailable ทุกครั้งก่อนทำรายการ

นี่เป็นสถานการณ์ที่นักพัฒนาพบบ่อย มีเอกสารสัญญา PDF ส่งเข้ามาและแผนกกฎหมายต้องการให้ลงลายเซ็นดิจิทัลก่อนจัดส่ง และเมื่อคุณพยายามใช้ไลบรารี PDFium ชุดเดิมที่เคยใช้เรนเดอร์และตรวจสอบเอกสาร ก็จะพบว่า PDFium ไม่สามารถเขียนลายเซ็นได้เลยเนื่องจาก API ลายเซ็นของมันจำกัดเฉพาะการอ่านอย่างเดียว PDFium Component เข้ามาแก้ปัญหานี้โดยควบคุมกระบวนการลงลายเซ็นทั้งหมดในภาษา Pascal ตั้งแต่ฟังก์ชันแฮชข้อมูลไปจนถึงขั้นตอนการแทรกข้อมูลในระดับไบต์ และบทความนี้จะอธิบายขั้นตอนการทำงานตลอดเส้นทาง

เหตุใด PDFium จึงไม่สามารถเขียนลายเซ็นดิจิทัลได้?

PDFium แสดงผลข้อมูลลายเซ็นเป็นวัตถุอ่านอย่างเดียวและไม่มีฟังก์ชันการสร้างลายเซ็นใหม่ ฟังก์ชันในตระกูล FPDFSignatureObj_* ช่วยให้คุณไล่ดูรายการลายเซ็นเดิมที่มีอยู่, อ่านส่วน /Contents และตรวจสอบช่วง /ByteRange ได้ แต่ไม่มีคำสั่งสำหรับสร้างพจนานุกรมลายเซ็น, จองพื้นที่ส่วน /Contents หรือบันทึกช่วงไบต์ แม้ว่าจะมีฟังก์ชันบันทึกแบบเพิ่มพูนอย่าง (FPDF_SaveAsCopy ร่วมกับ FPDF_INCREMENTAL) แต่ก็ไม่มีจุดสำหรับเชื่อมต่อการลงลายเซ็น ดังนั้นส่วนประกอบใดๆ ที่จะลงลายเซ็น PDF บนข้อกำหนดของ PDFium จะต้องสร้างข้อมูลไบต์ของลายเซ็นด้วยตนเองทั้งหมด นี่คือสาเหตุที่ PDFium Component สร้างกลไกนี้ขึ้นจากยูนิตภาษา Pascal บริสุทธิ์สามยูนิต โดยที่ตัวคอมไพเลอร์ FPC 3.2.2 มีการจัดส่ง md5 และ sha1 แต่ไม่มีฟังก์ชันตระกูล SHA-2 เลย และ API System.Hash SHA-256 ของ Delphi ก็ไม่สามารถทำงานร่วมกับ FPC ในระดับซอร์สโค้ดได้ ยูนิต FPdfSha256 จึงเป็นซอร์สที่สอดคล้องตามมาตรฐาน FIPS 180-4 ในตัว เพื่อให้พาธของ CMS ทำงานอยู่บนข้อมูลประเภท TSHA256Digest ชุดเดียวโดยไม่มีการแยกเงื่อนไขตามคอมไพเลอร์ ยูนิต FPdfAsn1 ทำหน้าที่เข้ารหัสและอ่านค่า DER ที่โครงสร้าง CMS ต้องการ และยูนิต FPdfCms จะทำหน้าที่ประกอบโครงสร้าง CAdES SignedData เหนือยูนิตทั้งสอง

คุณจะลงลายเซ็นดิจิทัลในเอกสาร PDF ใน Delphi ได้อย่างไร?

เพียงโหลดเอกสาร จากนั้นเรียกใช้เมธอด SignPades พร้อมระบุลายนิ้วมือใบรับรอง (certificate thumbprint) PDFium Component จะตรวจสอบค่าลายนิ้วมือนั้นกับคลังใบรับรองส่วนบุคคลของระบบ Windows (Current User "MY") ดึงข้อมูลใบรับรองและกุญแจส่วนตัวที่ตรงกัน แล้วเขียนบันทึกไฟล์ที่ลงลายเซ็นไปยังพาธที่คุณกำหนด

uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // the document to sign
    Pdf.Active := True;
    // Second argument: SHA-1 thumbprint of a certificate in the Current
    // User "MY" store. First argument: destination for the signed copy.
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

คุณสมบัติ PadesCryptoAvailable คือการตรวจสอบที่คุณควรเรียกดูเป็นอันดับแรกเสมอ บนระบบปฏิบัติการ Windows จะส่งคืนค่า True และระบบ crypt32/ncrypt เบื้องหลังจะเริ่มทำงาน ส่วนบนแพลตฟอร์มอื่นจะส่งกลับค่า False ซึ่งหากเรียกใช้คำสั่งเซ็นชื่อจะเกิดข้อผิดพลาด EPadesCrypto การใช้คุณสมบัตินี้ตรวจสอบความพร้อมก่อนจะช่วยป้องกันไม่ให้ซอฟต์แวร์รุ่น Linux หรือ macOS เกิดความล้มเหลวขณะรันไทม์ในเส้นทางทำงานที่ไม่รองรับ ลายนิ้วมือของใบรับรองคือค่าแฮช SHA-1 ของใบรับรอง ซึ่งเป็นค่าเดียวกับที่ตัวจัดการใบรับรองของ Windows แสดงในแท็บ Details และใช้สำหรับระบุผู้เซ็นอย่างชัดเจนโดยไม่ต้องบันทึกกุญแจสำคัญไว้ในซอร์สโค้ดของคุณ

ข้อมูลที่บันทึกใน CMS: แอตทริบิวต์ที่ลงลายเซ็นและ RFC 5652

ลายเซ็นมาตรฐาน PAdES ไม่ใช่ข้อมูลลายเซ็น RSA ดิบเหนือไฟล์ แต่เป็นโครงสร้าง CAdES CMS SignedData ที่เก็บกลุ่มแอตทริบิวต์บังคับ และฟังก์ชัน FPdfCms.BuildSignedData จะจัดทำข้อมูลกลุ่มแอตทริบิวต์ดังกล่าว ได้แก่ content-type, message-digest และ signing-certificate-v2 ซึ่งเป็นแอตทริบิวต์ ESS ที่เชื่อมโยงลายเซ็นเข้ากับใบรับรองผู้เซ็นด้วยแฮช รายละเอียดจุดหนึ่งที่นี่มักจะเป็นปัญหาสำหรับซอฟต์แวร์ที่เขียนโครงสร้าง CMS ขึ้นเอง เนื่องจากข้อกำหนด RFC 5652 ข้อ 5.4 กำหนดให้การคำนวณแฮชของแอตทริบิวต์จะต้องคำนวณบนการเข้ารหัส DER SET OF ที่มีแท็ก 0x31 ขณะที่แอตทริบิวต์เดียวกันจะถูกจัดเก็บในโครงสร้าง SignerInfo ภายใต้แท็ก IMPLICIT [0] คือรหัส 0xA0 ทำให้ PDFium Component ใช้วิธีเข้ารหัสกลุ่มแอตทริบิวต์เพียงครั้งเดียว คำนวณแฮชในรูปแบบ 0x31 แล้วเขียนทับเฉพาะไบต์แท็กเริ่มต้นให้เป็น 0xA0 สำหรับส่งออก ส่งผลให้บัฟเฟอร์เดียวสามารถทำหน้าที่ได้ทั้งสองอย่างโดยไม่ต้องประมวลผลโครงสร้างข้อมูลซ้ำสองรอบ

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signer in the MY store
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // hex width of /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

การระบุตัวเลือกเสริมจะช่วยเพิ่มข้อมูลเมทาดาตาของพจนานุกรมลายเซ็นตามที่ข้อกำหนด ISO 32000-1 ข้อ 12.8.1 กำหนด: ได้แก่ Reason, Location, ContactInfo และ Name ซึ่งทั้งหมดนี้เป็นทางเลือกและจะบันทึกในพจนานุกรมลายเซ็น อย่างไรก็ตาม มีข้อจำกัดประการหนึ่งที่ต้องระวัง หากคุณกำหนด CommitmentTypeOid เพื่อเพิ่มแอตทริบิวต์ CAdES commitment-type-indication อย่าระบุค่า Reason ควบคู่กันไป เนื่องจากข้อกำหนด ETSI EN 319 142-1 ข้อ 6.3 ห้ามไม่ให้ระบุข้อมูลทั้งสองร่วมกัน เพราะทั้งคู่เป็นการระบุจุดประสงค์การเซ็นลักษณะเดียวกันผ่านทางรูปแบบที่แตกต่างกัน

ขอบเขต ByteRange และพื้นที่เก็บข้อมูล /Contents ทำงานร่วมกันอย่างไร?

ลายเซ็นจะต้องครอบคลุมเนื้อหาไฟล์ทั้งหมด ยกเว้นเฉพาะกลุ่มไบต์ที่จัดเก็บตัวลายเซ็นเอง และมาตรฐาน PAdES จะจัดการปัญหานี้ด้วยการใช้พื้นที่จองล่วงหน้าที่มีขนาดคงที่ซึ่งบริหารจัดการโดยฟังก์ชัน SignPadesBytes ระบบจะจองสตริงฐานสิบหก /Contents ตามขนาด ContentsSize (ค่าเริ่มต้นคือ 16384 ไบต์ ซึ่งใหญ่เพียงพอสำหรับจัดเก็บโครงสร้าง CMS SignedData ทั่วไป) จัดโครงสร้างการบันทึกแบบเพิ่มพูนเพื่อระบุพิกัดออฟเซตของพื้นที่นั้น แล้วคำนวณขอบเขต /ByteRange เป็นสองช่วงที่ขนาบข้างพื้นที่จองล่วงหน้า ได้แก่ ข้อมูลทั้งหมดก่อนสัญลักษณ์เปิดของสตริงฐานสิบหก และข้อมูลทั้งหมดหลังสัญลักษณ์ปิดของสตริงฐานสิบหก ขั้นตอนแฮช SHA-256 จะทำงานเหนือขอบเขตข้อมูลทั้งสองส่วนนี้เท่านั้น ข้อมูล CMS ที่พร้อมใช้งานจะถูกเข้ารหัสแบบฐานสิบหกใส่ลงในพื้นที่จองล่วงหน้าดังกล่าว เติมเต็มค่าว่างด้วยศูนย์จนครบขนาด และแนบการระบุความเชื่อมโยงข้อมูลในตอนท้าย เนื่องจากขนาดของพื้นที่ได้รับการระบุไว้คงที่ตั้งแต่แรก การบันทึกข้อมูลลายเซ็นลงไปจึงไม่ส่งผลกระทบให้ข้อมูลไบต์ส่วนปลายขยับตำแหน่ง ซึ่งเป็นเหตุผลที่ขอบเขต ByteRange ยังคงถูกต้อง และทำให้ไบต์ข้อมูลเดิมยังคงครบถ้วนสมบูรณ์ ลายเซ็นเดิมที่เคยลงไว้ก่อนหน้าในไฟล์เดียวกันจึงยังใช้งานได้สอดคล้องตามข้อกำหนดการลงลายเซ็นแบบเพิ่มพูน ISO 32000-1 ข้อ 12.8.1

ระบบ Windows CNG เบื้องหลังและข้อจำกัดการทำงาน

PDFium Component จะทำการลงลายเซ็นเฉพาะบนระบบปฏิบัติการ Windows เท่านั้น และการจำกัดขอบเขตนี้ได้รับการออกแบบมาอย่างตั้งใจ ยูนิต FPdfCryptoWin เรียกใช้บริการของ crypt32.dll และ ncrypt.dll แบบไดนามิกโดยไม่มีการผูกไฟล์ DLL ในขั้นตอนคอมไพล์ และห่วงโซ่การลงลายเซ็นจะเป็นรูปแบบ CNG มาตรฐาน: เริ่มจากเปิดคลังใบรับรองส่วนบุคคล (MY store), ค้นหาใบรับรองด้วยแฮช, ดึงข้อมูลกุญแจส่วนตัวผ่านฟังก์ชัน CryptAcquireCertificatePrivateKey และเรียกใช้งาน NCryptSignHash โดยระบบรองรับโครงสร้างการทำงานทั้งแบบ RSA ร่วมกับ PKCS#1 v1.5, RSA-PSS และ ECDSA อย่างไรก็ตาม โครงสร้างแบบ ECDSA จำเป็นต้องมีการแก้ไขข้อมูลจุดหนึ่งที่แบบอื่นไม่ต้องทำ เนื่องจากคำสั่ง NCryptSignHash จะส่งกลับข้อมูลดิบ IEEE P1363 ที่เป็นคู่อัตลักษณ์ r และ s ขณะที่ข้อกำหนด CMS ต้องการโครงสร้างข้อมูล DER SEQUENCE แบบ ECDSA-Sig-Value ตัวระบบเบื้องหลังจึงต้องทำการแปลงข้อมูลส่วนนี้ตามข้อกำหนด RFC 5480 ใหม่

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // no signing backend on this platform

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

ผลกระทบในทางปฏิบัติคือ กุญแจส่วนตัวของใบรับรองจะต้องจัดเก็บอยู่ในคลังใบรับรองของระบบ Windows ใบรับรองที่อยู่ในรูปแบบไฟล์ PFX จะใช้งานได้ก็ต่อเมื่อคุณทำรายการนำเข้า (import) เข้าสู่คลังใบรับรองส่วนบุคคลก่อนเท่านั้น ซึ่งจะช่วยให้ระบบระบุลายนิ้วมือและส่งค่าไปประมวลผลผ่านคำสั่ง SignPades ได้ ซอฟต์แวร์เวอร์ชันนี้ไม่รองรับพาธสำหรับ PKCS#11 หรือ HSM และไม่มีระบบประมวลผลกุญแจซอฟต์แวร์ภายนอก ดังนั้นเมื่อคุณสมบัติ PadesCryptoAvailable ส่งกลับค่า False จะไม่มีความสามารถลงลายเซ็นใดๆ บนเครื่องนั้น

ข้อจำกัดของมาตรฐาน PAdES B-B

มาตรฐาน PAdES B-B คือระดับเริ่มต้นที่เป็นขั้นต่ำของระดับมาตรฐาน PAdES ทั้งสี่ระดับ: โดยลายเซ็นประเภทนี้จะทำหน้าที่พิสูจน์เพียงว่าใครเป็นผู้เซ็นเอกสารและไม่มีการแก้ไขไบต์ข้อมูลนับตั้งแต่วันที่เซ็นชื่อเท่านั้น ลายเซ็นระดับ B-B จะไม่มีการเก็บข้อมูลระบุเวลาที่เชื่อถือได้ จึงไม่สามารถพิสูจน์ได้ว่าการเซ็นเกิดขึ้นเมื่อใดอย่างชัดเจน และไม่มีการฝังข้อมูลการเพิกถอน ทำให้ผู้ตรวจสอบในอีกหลายปีข้างหน้าต้องไปค้นหาห่วงโซ่ใบรับรองและสถานะข้อมูลด้วยตนเอง ข้อจำกัดเหล่านี้คือสิ่งที่มาตรฐานระดับที่สูงขึ้นจะเข้ามาแก้ไข เมื่อคุณต้องการระบุเวลาเซ็นชื่อที่ผู้ตรวจสอบภายนอกยอมรับได้ หัวข้อ adding an RFC 3161 timestamp and DSS for long-term validation จะอธิบายขั้นตอนการอัปเกรดลายเซ็นเป็นระดับ B-T และระดับที่สูงกว่า และเมื่อต้องการอ่านข้อมูลลายเซ็นในเอกสารกลับมาและยืนยันระดับมาตรฐาน สามารถดูในหัวข้อ inspecting a PDF signature and its PAdES level และสุดท้าย ก่อนจะลงลายเซ็นในเอกสารใดๆ แนะนำให้อ่านหัวข้อ auditing a PDF for security risks เพื่อตรวจหาความเสี่ยงที่อาจเกิดขึ้น

เมธอด SignPades ที่แสดง ณ ที่นี้จัดทำร่วมกับ PDFium Component สำหรับ Delphi และ C++Builder ควบคู่ไปกับระบบตรวจสอบลายเซ็นแบบอ่านอย่างเดียวที่มีมาให้อยู่แล้วใน PDFium