HotPDF เข้ารหัส PDF ให้ผู้ถือ certificate ที่ระบุชื่อผ่าน security handler แบบ public-key ของ ISO 32000: EnablePubKeyEncryption รับ seed สุ่มขนาด 20 ไบต์ และ recipient แต่ละตัวได้ CMS envelope ของตัวเอง สร้างโดย AddPubKeyRecipientCertificate สำหรับคีย์ RSA (key transport แบบ RSA-OAEP) หรือ AddPubKeyAgreementRecipientWithSecret สำหรับคีย์วงรี (ECDH บน P-256, P-384, P-521, X25519 หรือ X448) ไม่มีใครต้องแชร์รหัสผ่าน ใครถือ private key ที่ตรงกันก็เปิดไฟล์ได้
use case ของมันมักเป็นเรื่องเดียวกันในเวอร์ชันต่าง ๆ ชุด audit รายไตรมาสส่งถึงผู้ตรวจสอบภายนอกสามคน ฝ่ายกฎหมายอยากให้ทุกคนอ่านได้ แต่ให้เปิดสิทธิ์พิมพ์แค่คนเดียว และไม่มีใครอยากให้รหัสผ่านนั่งอยู่ในเธรดอีเมลข้างไฟล์แนบ การเข้ารหัสแบบรหัสผ่านพูดสิ่งนี้ไม่ได้ การเข้ารหัสด้วย certificate พูดได้ เพราะ recipient ทุกตัวปลดล็อกเอกสารด้วยคีย์ที่ตัวเองถืออยู่แล้ว และ recipient แต่ละตัวแบกชุด permission ที่ต่างกันได้ใน envelope ของตัวเอง
การเข้ารหัส PDF ด้วย certificate ต่างจากรหัสผ่านอย่างไร
PDF ที่เข้ารหัสด้วย public-key กู้ file key จาก seed สุ่มบวกกับไบต์เป๊ะ ๆ ของ envelope recipient ทุกตัว ไม่ใช่จากอะไรที่คนพิมพ์ลงไป handler นี้อธิบายไว้ใน ISO 32000-1 §7.6.4 (§7.6.5 ใน ISO 32000-2) และ envelope เป็นโครงสร้าง CMS EnvelopedData ตามนิยามใน RFC 5652 HotPDF เขียน /Filter /Adobe.PubSec พร้อม /SubFilter /adbe.pkcs7.s5 สำหรับ AES-256 หมายถึง /V 5 กับ entry /DefaultCryptFilter ใต้ /CF ที่มี /CFM /AESV3 และ array /Recipients อาศัยอยู่ข้างใน crypt filter ตัวนั้น envelope แต่ละตัวเข้ารหัส 24 ไบต์: seed 20 ไบต์ตามด้วย permission word 32 บิตของ recipient ตัวนั้น ค่า /P ใน encryption dictionary เป็นแค่ placeholder เพราะ permission จริงเดินทางอยู่ข้างใน envelope แต่ละตัว ตอนโหลด reader จะเปิด envelope หนึ่งตัว กู้ seed กลับมา แล้ว hash seed รวมกับทุก envelope ตามลำดับใน /Recipients (SHA-256 สำหรับ AES-256, SHA-1 สำหรับ cipher รุ่นเก่า) เพื่อสร้าง file key ใหม่ ถ้าคุณยังชั่งใจอยู่ระหว่างโมเดลนี้กับรหัสผ่านธรรมดา คู่มือการเข้ารหัสด้วยรหัสผ่าน AES-256 กับธง permission ว่าด้วยอีกฝั่งของ trade-off นั้น
เขียน recipient แบบ RSA ด้วย EnablePubKeyEncryption
สำหรับ certificate แบบ RSA เรียก EnablePubKeyEncryption ด้วย aes256 แล้วเรียก AddPubKeyRecipientCertificate หนึ่งครั้งต่อ certificate ที่ encode แบบ DER ก่อน BeginDoc helper สร้าง envelope RSAES-OAEP ในโปรเซสเดียวกันด้วยค่า THPDFRSAOAEPHash สำหรับ OAEP digest กับ MGF1 digest (rohSHA256, rohSHA384 หรือ rohSHA512) และเข้ารหัสเนื้อหา envelope ด้วย AES-256-CBC
uses
System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFRSA;
procedure WriteAuditPack(const OutFile: string);
var
Pdf: THotPDF;
Seed: AnsiString;
begin
SetLength(Seed, 20); // เป๊ะ 20 ไบต์ แม้จะใช้ AES-256
AESGenerateRandomBytes(@Seed[1], Length(Seed));
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := OutFile;
Pdf.EnablePubKeyEncryption(Seed, aes256, True); // ชนิดคีย์ default คือ aes128
// ผู้ตรวจ A พิมพ์ได้; ผู้ตรวจ B อ่านกับดึงเนื้อหาได้อย่างเดียว
Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-a.cer'),
[prPrint, prPrint12bit, prExtractContent], rohSHA256, rohSHA256);
Pdf.AddPubKeyRecipientCertificate(TFile.ReadAllBytes('reviewer-b.cer'),
[prExtractContent]);
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 11);
Pdf.CurrentPage.TextOut(72, 720, 0, 'Q3 audit pack');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
สามรายละเอียดใน listing นั้นเป็นตัวรับน้ำหนัก อย่างแรก ความยาว seed ตายตัวที่ 20 ไบต์สำหรับทุกชนิดคีย์ รวม AES-256 EnablePubKeyEncryption โยน exception กับความยาวอื่น อย่างที่สอง EnablePubKeyEncryption default เป็น aes128 และ helper กลุ่ม certificate ทั้งคู่ปฏิเสธทำงานเว้นแต่ชนิดคีย์เป็น aes256 ลืมอาร์กิวเมนต์ตัวที่สองไปจึงได้ exception "certificate envelopes require aes256" cipher รุ่นเก่า (k40, k128, aes128) ยังใช้ได้ แต่ผ่าน AddPubKeyRecipient กับ envelope ที่คุณสร้างที่อื่นเท่านั้น อย่างที่สาม การเข้ารหัสแบบ public-key ด้วย AES-256 เป็นฟีเจอร์ PDF 2.0 HotPDF จึงยกเวอร์ชันเอกสารเป็น 2.0 ให้อัตโนมัติ เมื่อตั้ง StrictVersionLock ไว้บนเวอร์ชันต่ำกว่า EnablePubKeyEncryption คืนค่าโดยไม่เปิดใช้อะไรเลย ความล้มเหลวโผล่ในบรรทัดถัดไปว่า "call EnablePubKeyEncryption first" เท่านั้น การสลับการเข้ารหัสกลาง incremental update โยน EInvalidOpException ทันที
เพิ่ม recipient แบบ ECDH: P-256, P-384, P-521, X25519 กับ X448
สำหรับ certificate แบบวงรี AddPubKeyAgreementRecipientWithSecret เขียน recipient แบบ key agreement ของ CMS (KeyAgreeRecipientInfo โครงสร้าง KARI จาก RFC 5753 พร้อมโปรไฟล์ X25519 กับ X448 จาก RFC 8418) และคำนวณ shared secret ของ ECDH ในโปรเซสเดียวกัน คุณเลือกเส้นโค้งด้วยค่า THPDFPubKeyAgreementScheme: pkasECDHP256, pkasECDHP384, pkasECDHP521, pkasX25519 หรือ pkasX448 scheme ต้องตรงกับคีย์ใน certificate ไม่งั้น call นั้นโยน "Certificate key does not match the requested agreement scheme" เบื้องลึก envelope แต่ละตัวได้ UKM สุ่ม 32 ไบต์ใหม่เอี่ยม, key-encryption key ที่ derive ด้วย KDF แบบ stdDH (SHA-256 สำหรับ P-256 กับ X25519, SHA-384 สำหรับ P-384, SHA-512 สำหรับ P-521 กับ X448) และ key wrap AES-256 ตามนิยามใน RFC 3394 ตัว shared secret เองมาจากโค้ดเส้นโค้ง pure Pascal ล้วน ไม่มี crypto provider ของแพลตฟอร์มแทรก บทความเรื่องเลขคณิตเส้นโค้ง NIST ด้วย pure Pascal อธิบายว่าชั้นนั้นถูกสร้างและตรวจสอบอย่างไร สำหรับเส้นโค้ง Montgomery คู่คีย์ชั่วคราวทั้งคู่สร้างในเครื่องได้:
uses
System.SysUtils, System.IOUtils, HPDFDoc, HPDFCrypt, HPDFPubSec,
HPDFKeyAgreement;
procedure AddLegalRecipient(Pdf: THotPDF);
var
Scalar, OriginatorPublic: TBytes;
begin
// scalar ชั่วคราวตัวใหม่ต่อหนึ่ง envelope; clamping เกิดข้างใน ladder
SetLength(Scalar, 32);
AESGenerateRandomBytes(@Scalar[0], Length(Scalar));
try
OriginatorPublic := HPDFX25519PublicFromScalar(Scalar);
Pdf.AddPubKeyAgreementRecipientWithSecret(
TFile.ReadAllBytes('legal-x25519.cer'),
[prPrint, prExtractContent], pkasX25519,
OriginatorPublic, Scalar,
[]); // OwnPublicPoint: มีความหมายเฉพาะกับเส้นโค้ง NIST
finally
HPDFSecureClearBytes(Scalar);
end;
end;
เส้นโค้ง NIST ทวงของจาก caller มากกว่า HotPDF แจก helper public-key เฉพาะ X25519 กับ X448 (HPDFX25519PublicFromScalar, HPDFX448PublicFromScalar) สำหรับ P-256, P-384 กับ P-521 คุณต้องสร้างคู่คีย์ชั่วคราวด้วยเครื่องมือของตัวเองแล้วส่ง scalar big-endian ขนาดเป๊ะเท่าฟิลด์ (32, 48 หรือ 66 ไบต์) บวกกับจุดแบบไม่บีบอัด 0x04||X||Y ที่คู่กันเป็น OriginatorPublicKey HotPDF validate จุดของ recipient เทียบกับสมการของเส้นโค้ง แต่มันเช็กไม่ได้ว่า originator public key ของคุณเป็นของ scalar ตัวนั้นจริง ๆ ครึ่งที่ไม่ตรงกันยังผลิต envelope ที่รูปร่างสมบูรณ์แต่ไม่มี recipient ตัวไหนเปิดได้ นี่คือเหตุผลที่การโหลดแบบไปกลับควรอยู่ใน test suite ของคุณ ไม่ใช่แค่เช็กขนาดไฟล์
ทำไมลำดับของ /Recipients ถึงสำคัญ
ลำดับของ /Recipients สำคัญเพราะ file key คือ digest ครอบ seed กับทุก envelope ตามลำดับ array ฝั่งเขียนกับฝั่งอ่านจึงต้อง hash ไบต์ชุดเดียวกันตามลำดับเดียวกัน HotPDF เก็บ envelope ตามลำดับที่คุณเพิ่มและเขียนมันออกไปแบบไม่แตะ คุณจะเพิ่ม recipient ลำดับไหนก็ได้ตามใจ แต่อะไรก็ห้ามจัดเรียงใหม่, encode ใหม่ หรือ "จัดระเบียบ" array นั้น บั๊กจริงส่วนใหญ่ในแดนนี้เป็นเวอร์ชันต่าง ๆ ของธีมเดียวกัน คือสองฝั่ง hash ไบต์ที่ต่างกันเล็กน้อย:
- เก็บ dynamic array ลง
TListผ่านAddแค่เก็บ pointer ดิบ ส่วน reference count ยังติดกับตัวแปรท้องถิ่นSetLengthครั้งถัดไป free buffer และอาจเอามันกลับมาใช้ใหม่ ทุกช่องเลย alias ไปที่ envelope ตัวสุดท้าย ไฟล์หลาย recipient จึง derive key ผิด ทางแก้คือเก็บสำเนาที่ตัวเองเป็นเจ้าของด้วยList.Add(Pointer(System.Copy(Bytes))) - การ unwrap envelope parse DER ในที่ และรอบกู้ key เดิม hash array ที่ยังมีชีวิตชุดเดิมพวกนั้น reader ตอนนี้ snapshot สำเนา pristine ของทุก envelope ก่อนการ unwrap ใดจะแตะพวกมัน และ digest วิ่งบน snapshot
- DER แบบไบนารีที่ผ่าน
TStringListแบบ Unicode ไบต์ที่$80ขึ้นไปถูก re-encode โดย code page HotPDF จึงเก็บ envelope เป็นข้อความ hex ภายใน - สตริงแบบเข้ารหัสและไบนารีต้องถูกเขียนเป็น hex string string แบบ literal ตกอยู่ใต้การ normalize จุดจบบรรทัด ที่ CR, LF กับ CRLF กลายเป็น LF เดียว (ISO 32000-1 §7.3.4.2) ซึ่งเขียนทับ ciphertext อย่างเงียบ ๆ HotPDF ปล่อย entry
/Recipientsทุกตัวเป็น hex string และยกเว้นจากการเข้ารหัสสตริง เพราะ reader ทุกตัวต้องมี envelope ก่อนจะถือคีย์ใด ๆ - ไบต์แรกของ DER
BIT STRINGนับบิตที่ไม่ใช้และต้องเป็นศูนย์สำหรับคีย์ที่ตรงขอบไบต์ ปล่อยมันไม่ initialize หลังSetLengthคือเขียนอะไรก็ได้ที่ค้างบน stack และ unwrapper ที่เข้มงวดปฏิเสธ originator key ไฟล์จึงบางครั้งเปิดไม่ได้ด้วยคีย์ที่มันถูกเขียนขึ้นเพื่อคีย์นั้นเอง - เมื่อคีย์ตัวเดิมยังถอดรหัสไม่ได้ ให้เทียบทีละชั้น: file key แล้ว prefix ของ ciphertext (คือ IV) แล้ว object key แล้ว plaintext บั๊กอาศัยอยู่ทันทีหลังชั้นแรกที่ไม่เห็นพ้อง
จะเปิด PDF ที่เข้ารหัสด้วย certificate โดยใช้ private key อย่างไร
การเปิด PDF ที่เข้ารหัสด้วย certificate ต้องลงทะเบียน private key ก่อนเรียก LoadFromFile เพราะ HotPDF กู้ file key ระหว่างรอบอ่านโครงสร้าง กำหนดคีย์ RSA หรือ EC ที่ parse ด้วย HPDFParsePFX ให้ PubSecKeyMaterial เพิ่มคีย์ RSA ตัวอื่นด้วย AddPubSecKeyMaterial และลงทะเบียน scalar ECDH ดิบด้วย AddPubSecAgreementKeyMaterial(CurveOID, PrivateScalar, OwnPublicPoint) โดยใช้ค่าคงที่ HPDFOIDX25519, HPDFOIDX448, HPDFOIDECP256, HPDFOIDECP384 หรือ HPDFOIDECP521 เส้นโค้ง NIST ทวงจุดสาธารณะแบบไม่บีบอัดของ recipient เอง ส่วนเส้นโค้ง Montgomery เมินมัน
uses
System.SysUtils, System.IOUtils, HPDFDoc, HPDFPFX, HPDFKeyAgreement;
procedure OpenAuditPack(const LegalScalar: TBytes);
var
Reader: THotPDF;
begin
Reader := THotPDF.Create(nil);
try
Reader.AutoLaunch := False;
Reader.PubSecKeyMaterial :=
HPDFParsePFX(TFile.ReadAllBytes('reviewer-a.pfx'), 'pfx-password');
Reader.AddPubSecAgreementKeyMaterial(HPDFOIDX25519, LegalScalar, nil);
// เสริม: เลือก envelope ตรง ๆ แทนการลองทุกตัว
Reader.PubSecRecipientQuery :=
function(Context: Pointer; RecipientCount: Integer): Integer
begin
Result := -1; // -1 = ลองทุก envelope ตามลำดับ
end;
Reader.LoadFromFile('audit-pack.pdf', '');
Writeln('Pages: ', Reader.GetLoadedPageCount);
finally
Reader.Free;
end;
end;
ถ้าไม่ให้ callback HotPDF จะลองทุก envelope เทียบกับคีย์ที่ลงทะเบียนทุกตัว: คีย์หลักก่อน ตามด้วยคีย์ RSA เพิ่มเติมแต่ละตัว แล้วจบที่ material EC PubSecRecipientQuery รับจำนวน envelope และคืน index เริ่มที่ศูนย์หรือ -1 ส่วน index ที่หลุดนอก array จะโยน exception แทนที่จะถูก clamp ข้อสังเกตคือ AddPubSecKeyMaterial รับแต่ material RSA (มันยืนกรานขอ modulus กับ private exponent) คีย์ EC จึงอยู่ใน PubSecKeyMaterial หรือ AddPubSecAgreementKeyMaterial เมื่อไม่มีคีย์ไหนเปิด envelope ใดได้ ขั้นกู้คืนจะคืนค่าโดยไม่มี file key แทนที่จะโยน ให้ตรวจว่าเนื้อหาที่คาดไว้ถูกถอดรหัสจริง แทนที่จะไว้ใจว่า call โหลดคืนค่ามาแล้ว
สิ่งที่ HotPDF ไม่การันตี
HotPDF การันตีว่า writer กับ reader ของตัวเองเห็นตรงกันไบต์ต่อไบต์ และสร้าง envelope ที่ทำตามโครงสร้าง CMS ที่อ้างไว้ข้างบน แต่ไม่การันตีว่า PDF viewer ทุกตัวเปิดทุกคู่ผสมได้ การรองรับ key transport แบบ RSA-OAEP และ recipient แบบ X25519 หรือ X448 ต่างกันไปตาม reader และเวอร์ชัน และเราไม่เคยเผยแพร่ผลความเข้ากันได้สำหรับคู่ผสมพวกนั้น ถ้าเอกสารต้องเปิดใน viewer เฉพาะตัว ให้เข้ารหัสไฟล์ทดสอบด้วย certificate ทดสอบที่ใช้คีย์ชนิดเดียวกันแล้วเปิดในตัวนั้นก่อนตัดสินใจเลือก scheme permission ที่แบกไว้ใน envelope ยังเป็นนโยบายที่ซอฟต์แวร์ที่ทำตามสเปกเคารพ อย่างเดียวกับที่เป็นในการเข้ารหัสด้วยรหัสผ่าน คุณภาพของ seed ก็เป็นความรับผิดชอบของคุณเช่นกัน AESGenerateRandomBytes มีไว้สำหรับงานนี้ และ HotPDF เช็ดสำเนา seed ของตัวเองทิ้งเมื่อ file key ถูก derive แล้ว ถ้าคุณยังต้องการให้ string, stream หรือไฟล์แนบใช้ crypt filter คนละตัว คู่มือนโยบาย crypt filter สำหรับ StmF, StrF และ EFF บอกว่าชื่อ filter ไหนที่ public-key handler ยอมรับ
การเข้ารหัสด้วย certificate, envelope recipient แบบ RSA-OAEP กับ ECDH และการโหลด private key ship มาทั้งหมดในHotPDF Delphi PDF component ควบคู่กับการเข้ารหัสด้วยรหัสผ่าน, ลายเซ็นดิจิทัลและชุดเครื่องมือ ISO 32000 ที่เหลือสำหรับ Delphi และ C++Builder