ฟังก์ชัน PLCreateSelfSignedCertificate ของ PDFlibPas สร้างใบรับรอง RSA/SHA-256 แบบเซ็นด้วยตัวเอง และ export มัน รวม private key เข้าไปในไฟล์ PFX ที่ป้องกันด้วยรหัสผ่านโดยตรง โดยใช้แค่ Win32 CryptoAPI ที่ติดตั้งอยู่แล้วในทุกเครื่อง Windows ไม่มีเครื่องมือภายนอก ไม่มี certificate authority ไม่มีขั้นตอน makecert หรือ OpenSSL ด้วยมือ เรียกฟังก์ชันครั้งเดียว ได้ใบรับรองที่ดีพอสำหรับขับเคลื่อนการทดสอบการเซ็น
สถานการณ์ที่ทำให้ฟังก์ชันนี้คุ้มค่าที่จะมีเกือบทุกครั้งคือ CI pipeline การทดสอบ smoke test การเซ็นต้องการ PFX จริงพร้อม private key จริงเบื้องหลังมัน และการ commit ตัวหนึ่งเข้า repository เป็นปัญหาความปลอดภัยของตัวเอง เพราะ private key ที่ commit ไว้คือ private key ที่รั่วไหลตั้งแต่วินาทีที่ commit นั้นลงเอย การ shell out ไปยัง makecert.exe หรือการเรียก OpenSSL จาก build script ก็ใช้ได้เช่นกัน แต่แล้ว pipeline ก็ต้องพึ่งพาเครื่องมือที่ต้องติดตั้ง หาเจอบน PATH และรักษาเวอร์ชันให้สอดคล้องกันข้ามทุก build agent การสร้างใบรับรองภายในโปรเซสเดียวกับที่รันการทดสอบ ด้วยการเรียก Win32 CryptoAPI เดียวกับที่ Windows แถมมาอยู่แล้ว ลบ dependency นั้นออกไปโดยสิ้นเชิง
PLCreateSelfSignedCertificate สร้างอะไรจริงๆ
PLCreateSelfSignedCertificate สร้างไฟล์ PFX ที่ป้องกันด้วยรหัสผ่านซึ่งถือใบรับรอง RSA แบบเซ็นด้วยตัวเองและ private key ของมัน เซ็นด้วย sha256RSA ขับเคลื่อนด้วยพารามิเตอร์ห้าตัว คือ SubjectName, PFXFileName, PFXPassword, ValidDays และ KeyBits และมันคืนค่า flag ความสำเร็จแบบ Boolean ธรรมดา SubjectName รับ string X.500 เต็มรูปแบบอย่าง 'CN=Alice, O=Example' และชื่อเปล่าที่ไม่มีเครื่องหมาย = เลยจะถูกเติม CN= นำหน้าให้โดยอัตโนมัติ ValidDays ต่ำกว่า 1 จะ fallback เป็น 365 และ KeyBits นอกช่วง 1024 ถึง 16384 จะ fallback เป็น 2048 PDFlibPas มาพร้อมฟังก์ชันนี้ตั้งแต่ v3.224.0 เข้าถึงได้ไม่ใช่แค่จาก unit Delphi เท่านั้น แต่ผ่านพื้นผิว DLL และ ActiveX ด้วย และ doc comment ของมันเองก็ตรงไปตรงมาว่ามันหยุดมีประโยชน์ตรงไหน viewer หลักๆ ทุกตัวทำเครื่องหมายใบรับรองแบบเซ็นด้วยตัวเองว่าไม่น่าเชื่อถือ เว้นแต่จะมีใครติดตั้งมันอย่างชัดเจน ดังนั้นให้ปฏิบัติต่อสิ่งที่มันสร้างขึ้นเป็นใบรับรองสำหรับทดสอบ code path ไม่ใช่ลายเซ็นที่ใครก็ตามนอกทีมของคุณควรถูกขอให้พึ่งพา
var
Success: Boolean;
begin
Success := PLCreateSelfSignedCertificate(
'CN=PDFlibPas CI Test, O=Example Corp',
'ci-test-signer.pfx',
'a-strong-throwaway-password',
365, // ValidDays
2048); // KeyBits
if not Success then
raise Exception.Create('Self-signed certificate generation failed');
end;
ทำไม CryptGenKey ถึงเข้ารหัสความยาวคีย์ไว้ในพารามิเตอร์ flag
CryptGenKey บรรจุการตั้งค่าที่ไม่เกี่ยวข้องกันสองอย่างเข้าไปในพารามิเตอร์ dwFlags เดียว word ล่างพก flag พฤติกรรม รวมถึง CRYPT_EXPORTABLE ในขณะที่ word บน สำหรับคีย์แลกเปลี่ยน RSA พกความยาวคีย์ที่ขอเป็นบิต การส่ง 2048 เหมือนเป็นแค่ flag อีกตัวจะลงเอยที่ word ล่างแทน ซึ่งไม่ตรงกับ flag พฤติกรรมใดที่ CryptoAPI นิยามไว้เลย ดังนั้นการเรียกจึงสร้างคีย์ที่ความยาวเริ่มต้นใดก็ตามที่ provider fallback ไป แทนที่จะเป็นความยาวที่ผู้เรียกคิดว่าตัวเองขอไป การได้คีย์ RSA 2048 บิตจริงๆ หมายถึงการเลื่อนตัวเลขเข้า word บนก่อน
// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
(Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
Exit;
เกิดอะไรขึ้นถ้าคุณลืม CRYPT_EXPORTABLE
ทิ้ง CRYPT_EXPORTABLE ออกจากค่า flag เดียวกันนั้น แล้ว CryptGenKey ก็ยังคงสำเร็จอยู่ แต่มันทำเครื่องหมาย private key ที่สร้างขึ้นว่า export ไม่ได้ในระดับ CSP ทุกอย่างที่ปลายทางยังคงรายงานความสำเร็จเช่นกัน CertCreateSelfSignCertificate คืน certificate context ที่ถูกต้อง และ PFXExportCertStoreEx แม้จะเรียกด้วย EXPORT_PRIVATE_KEYS ก็ยังคงสำเร็จอยู่ดี และเขียนไฟล์ PFX ที่เปิดได้, parse ได้ และดูเหมือนปกติทุกประการ สิ่งที่มันไม่มีคือ private key เพราะ CSP ปฏิเสธที่จะปล่อยมันออกจาก key container และ PFXExportCertStoreEx ไม่เคยปฏิบัติต่อการปฏิเสธนั้นว่าเป็นเหตุผลให้ export ทั้งหมดล้มเหลว
ความล้มเหลวปรากฏขึ้นภายหลังเท่านั้น และที่อื่นโดยสิ้นเชิง การเรียกเซ็นเปิด PFX นั้น พบใบรับรองที่ไม่มี private key ผูกอยู่ และรายงาน error แบบเดียวกันเป๊ะกับที่คุณจะได้จาก PFX ที่เสียหายหรือผิด ไม่ใช่จาก flag ที่หายไปสามชั้นก่อนหน้า ใครก็ตามที่ debug จากฝั่งการเซ็นเพียงอย่างเดียวสามารถเสียเวลาไปทั้งบ่ายกับไฟล์ที่ผิด ก่อนที่จะรู้ว่าบั๊กจริงๆ คือบิตเดียวที่หายไปตอนสร้างคีย์ ในการเรียกฟังก์ชันที่ต่างกันโดยสิ้นเชิง อาจอยู่ใน build script ที่ต่างกันโดยสิ้นเชิงด้วยซ้ำ
ทำไม ProvType ถึงต้องตรงกันระหว่าง CryptAcquireContextW กับใบรับรอง
ProvType ต้องตรงกันเพราะ CertCreateSelfSignCertificate resolve private key ของใบรับรองใหม่ผ่าน record CRYPT_KEY_PROV_INFO และหนึ่งฟิลด์ใน record นั้นคือ ProvType ต้องระบุค่าประเภท CSP ตัวเดียวกันเป๊ะกับที่ส่งให้ CryptAcquireContextW ตอนที่ key container ถูกเปิด คือ PROV_RSA_AES ซึ่งเป็นเลข 24 ใน implementation ของ PDFlibPas ตั้ง ProvType เป็นศูนย์ หรือเป็นค่าคงที่ provider ใดก็ตามอื่นที่ไม่ใช่ตัวที่ container นั้นเป็นของจริงๆ แล้วใบรับรองก็ยังคงถูกสร้างได้อยู่ แต่ลิงก์ที่บันทึกไว้กลับไปยัง private key ของมันไม่ resolve ไปยัง container ที่ถือมันอีกต่อไป ซึ่งปรากฏขึ้นภายหลังเป็นความล้มเหลวในการเซ็นหรือ export ที่ไม่เกี่ยวอะไรเลยกับเนื้อหาด้าน cryptographic จริงของใบรับรอง
// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES; // same constant, both call sites
การรวมทุกอย่างเข้าด้วยกัน: จาก GUID container สู่ PFX ที่ป้องกันด้วยรหัสผ่าน
call chain ภายใน PLCreateSelfSignedCertificate เดินตามเส้นตรงเดียว เปิด key container ใหม่ที่ตั้งชื่อตาม GUID ที่สร้างขึ้นใหม่ เพื่อไม่ให้การรัน CI พร้อมกันชนกันเรื่องชื่อ container เลย สร้างคู่คีย์ RSA ภายในมันด้วย flag สองตัวที่ครอบคลุมข้างต้น เข้ารหัส SubjectName เป็น X.500 name blob ผ่าน CertStrToNameW และเรียก CertCreateSelfSignCertificate ด้วยหน้าต่างความถูกต้องที่คำนวณจาก ValidDays และส่งมาเป็นโครงสร้างรูปร่าง SYSTEMTIME ธรรมดา certificate context ที่ได้เข้าไปใน certificate store ในหน่วยความจำที่เปิดด้วย CertOpenStore และ CERT_STORE_PROV_MEMORY เพียงเพื่อให้ PFXExportCertStoreEx มี store ให้ export เท่านั้น เพราะ API นั้นทำงานกับ store handle แทนที่จะเป็น certificate context เปล่าๆ
// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_DELETEKEYSET);
PFXExportCertStoreEx เองเดินตามข้อตกลง Win32 แบบสองรอบปกติ เรียกมันครั้งหนึ่งด้วย buffer ความยาวศูนย์เพื่อรู้ว่า PFX ต้องการกี่ไบต์ จัดสรรเท่านั้น แล้วเรียกมันอีกครั้งเพื่อเติม buffer เมื่อไบต์อยู่บนดิสก์แล้ว PDFlibPas จะลบ key container แบบใช้แล้วทิ้งด้วย CRYPT_DELETEKEYSET แทนที่จะทิ้งมันไว้ เพราะ PFX ถือสำเนาของทุกไบต์ของ key material ที่ container นั้นถืออยู่แล้ว ข้ามการล้างข้อมูลนั้นไปและทุกการเรียก PLCreateSelfSignedCertificate จะทิ้ง key container ที่ตั้งชื่อด้วย GUID ที่เป็นกำพร้าไว้ในโปรไฟล์ของผู้ใช้ที่เรียก ซึ่งเป็นการรั่วไหลประเภทพอดีที่ CI agent ที่รันฟังก์ชันนี้ในทุก build จะสะสมไปเป็นเดือนก่อนที่จะมีใครสังเกตเห็น
ใบรับรองแบบเซ็นด้วยตัวเองปลอดภัยที่จะใช้เซ็น production หรือไม่
ไม่ ใบรับรองแบบเซ็นด้วยตัวเองปลอดภัยสำหรับทดสอบ code path การเซ็น และไม่ปลอดภัยสำหรับลายเซ็นที่ใครก็ตามนอกทีมถูกคาดหวังให้เชื่อถือ เพราะไม่มีอะไรที่เชื่อมมันกลับไปยัง root ที่ซอฟต์แวร์ของ relying party เชื่อถืออยู่แล้วเลย ขั้นตอนถัดไปตามธรรมชาติสำหรับ PFX แบบนี้คือการเรียกเซ็นจริง ครอบคลุมในการสร้างเวิร์กเบนช์ด้านความสอดคล้องและการเซ็นใน Delphi ด้วย PDFlibPas ที่ PFX ที่สร้างแบบนี้ขับเคลื่อนครึ่งการเซ็นของ pipeline ที่รัน PDF/A preflight และ audit ByteRange ด้วย การเซ็นเป็นแค่ครึ่งหนึ่งของสิ่งที่อยู่รอบใบรับรอง และอีกครึ่งคือจุดที่ leaf แบบเซ็นด้วยตัวเองควรล้มเหลวพอดี การเซ็นและตรวจสอบ PAdES ใน Delphi ด้วย PDFlibPas ครอบคลุมการตรวจสอบ chain-of-trust ที่ validator ความสอดคล้องรัน และ validator ที่เดิน chain กลับไปยัง root ที่เชื่อถือได้ไม่มีเหตุผลที่จะเชื่อใบรับรองที่ฟังก์ชันนี้สร้างขึ้นมาเมื่อห้านาทีก่อนจากความว่างเปล่าเลย
PLCreateSelfSignedCertificate เป็นหนึ่งฟังก์ชันในบรรดา API ด้านใบรับรองและการเซ็นในไลบรารี PDF PDFlibPas สำหรับ Delphi และ C++Builder และมันมีอยู่เพื่อช่องว่างพอดีที่อธิบายตรงนี้ คือการทดสอบการเซ็นที่ต้องการคู่คีย์จริงเบื้องหลังมัน และไม่มีอะไรภายนอกให้ต้องสร้างมันเลย