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

ใบรับรองทดสอบแบบเซ็นด้วยตัวเองใน Delphi ด้วย CryptoAPI

ฟังก์ชัน PLCreateSelfSignedCertificate ของ PDF Library for Delphi สร้างใบรับรอง 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 PDF Library for Delphi มาพร้อมฟังก์ชันนี้ตั้งแต่ v3.224.0 เข้าถึงได้ไม่ใช่แค่จาก unit Delphi เท่านั้น แต่ผ่านพื้นผิว DLL และ ActiveX ด้วย และ doc comment ของมันเองก็ตรงไปตรงมาว่ามันหยุดมีประโยชน์ตรงไหน viewer หลักๆ ทุกตัวทำเครื่องหมายใบรับรองแบบเซ็นด้วยตัวเองว่าไม่น่าเชื่อถือ เว้นแต่จะมีใครติดตั้งมันอย่างชัดเจน ดังนั้นให้ปฏิบัติต่อสิ่งที่มันสร้างขึ้นเป็นใบรับรองสำหรับทดสอบ code path ไม่ใช่ลายเซ็นที่ใครก็ตามนอกทีมของคุณควรถูกขอให้พึ่งพา

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDF Library for Delphi 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 บนก่อน

แผนภาพ PDF Library for Delphi ของพารามิเตอร์ dwFlags ของ CryptGenKey ที่แยกเป็น high word เก็บ KeyBits shl 16 ในฐานะความยาวคีย์ RSA และ low word เก็บ flag พฤติกรรม เช่น CRYPT_EXPORTABLE
ค่า 2048 เปล่า ๆ ตกลงไปในครึ่งฟลากพฤติกรรม ผู้ให้บริการจึงถอยไปใช้ความยาวกุญแจเริ่มต้นอย่างเงียบ ๆ
// ความยาวคีย์อยู่ใน 16 บิตบนของ flag ของ CryptGenKey;
// ส่วน word ล่างพก flag พฤติกรรม เช่น 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 ที่ต่างกันโดยสิ้นเชิงด้วยซ้ำ

แผนภาพ PDF Library for Delphi ของลูกโซ่ล้มเหลวเงียบที่เกิดจากการละ CRYPT_EXPORTABLE โดยการสร้างคีย์ การสร้างใบรับรอง และการส่งออก PFX รายงานสำเร็จทั้งหมด จนกระทั่งการเรียกลงลายเซ็นภายหลังไม่พบ private key ใน PFX
การละเว้น CRYPT_EXPORTABLE ให้ PFX หน้าตาธรรมดา ซึ่งกุญแจส่วนตัวที่หายไปจะปรากฏเฉพาะตอนลงนามเท่านั้น

ทำไม ProvType ถึงต้องตรงกันระหว่าง CryptAcquireContextW กับใบรับรอง

ProvType ต้องตรงกันเพราะ CertCreateSelfSignCertificate resolve private key ของใบรับรองใหม่ผ่าน record CRYPT_KEY_PROV_INFO และหนึ่งฟิลด์ใน record นั้นคือ ProvType ต้องระบุค่าประเภท CSP ตัวเดียวกันเป๊ะกับที่ส่งให้ CryptAcquireContextW ตอนที่ key container ถูกเปิด คือ PROV_RSA_AES ซึ่งเป็นเลข 24 ใน implementation ของ PDF Library for Delphi ตั้ง ProvType เป็นศูนย์ หรือเป็นค่าคงที่ provider ใดก็ตามอื่นที่ไม่ใช่ตัวที่ container นั้นเป็นของจริงๆ แล้วใบรับรองก็ยังคงถูกสร้างได้อยู่ แต่ลิงก์ที่บันทึกไว้กลับไปยัง private key ของมันไม่ resolve ไปยัง container ที่ถือมันอีกต่อไป ซึ่งปรากฏขึ้นภายหลังเป็นความล้มเหลวในการเซ็นหรือ export ที่ไม่เกี่ยวอะไรเลยกับเนื้อหาด้าน cryptographic จริงของใบรับรอง

// ประเภท provider ที่ใช้เปิด key container ต้องตรงกับ
// ประเภท provider ที่บันทึกไว้ใน key-provider info ของใบรับรอง
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... สร้างคีย์ สร้าง subject name blob แล้วจากนั้น:
KeyProvInfo.ProvType := PROV_RSA_AES;   // ค่าคงที่เดียวกัน ทั้งสองจุดที่เรียก

การรวมทุกอย่างเข้าด้วยกัน: จาก 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 เปล่าๆ

// แต่ละครั้งที่เรียกจะเปิด container แบบใช้แล้วทิ้งที่ตั้งชื่อตาม GUID ใหม่:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... สร้างคีย์ เซ็นใบรับรองด้วยตัวเอง export เป็น PFX ...
// แล้วลบ container ทิ้งเมื่อ PFX มีสำเนาของคีย์เป็นของตัวเองแล้ว:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx เองเดินตามข้อตกลง Win32 แบบสองรอบปกติ เรียกมันครั้งหนึ่งด้วย buffer ความยาวศูนย์เพื่อรู้ว่า PFX ต้องการกี่ไบต์ จัดสรรเท่านั้น แล้วเรียกมันอีกครั้งเพื่อเติม buffer เมื่อไบต์อยู่บนดิสก์แล้ว PDF Library for Delphi จะลบ key container แบบใช้แล้วทิ้งด้วย CRYPT_DELETEKEYSET แทนที่จะทิ้งมันไว้ เพราะ PFX ถือสำเนาของทุกไบต์ของ key material ที่ container นั้นถืออยู่แล้ว ข้ามการล้างข้อมูลนั้นไปและทุกการเรียก PLCreateSelfSignedCertificate จะทิ้ง key container ที่ตั้งชื่อด้วย GUID ที่เป็นกำพร้าไว้ในโปรไฟล์ของผู้ใช้ที่เรียก ซึ่งเป็นการรั่วไหลประเภทพอดีที่ CI agent ที่รันฟังก์ชันนี้ในทุก build จะสะสมไปเป็นเดือนก่อนที่จะมีใครสังเกตเห็น

แผนภาพ PDF Library for Delphi ของห่วงโซ่การเรียก PLCreateSelfSignedCertificate จาก key container ชื่อ GUID ผ่าน CryptGenKey, CertStrToNameW และ CertCreateSelfSignCertificate ที่จับคู่ ProvType ตรงกัน ไปสู่การส่งออก PFX สองรอบ ตามด้วยการลบคอนเทนเนอร์
คอนเทนเนอร์ GUID แบบใช้แล้วทิ้งป้อนห่วงโซ่ CryptoAPI ตรง ๆ ที่จบลงที่ PFX และถูกลบทิ้งทันทีหลังจากนั้น

ใบรับรองแบบเซ็นด้วยตัวเองปลอดภัยที่จะใช้เซ็น production หรือไม่

ไม่ ใบรับรองแบบเซ็นด้วยตัวเองปลอดภัยสำหรับทดสอบ code path การเซ็น และไม่ปลอดภัยสำหรับลายเซ็นที่ใครก็ตามนอกทีมถูกคาดหวังให้เชื่อถือ เพราะไม่มีอะไรที่เชื่อมมันกลับไปยัง root ที่ซอฟต์แวร์ของ relying party เชื่อถืออยู่แล้วเลย ขั้นตอนถัดไปตามธรรมชาติสำหรับ PFX แบบนี้คือการเรียกเซ็นจริง ครอบคลุมในการสร้างเวิร์กเบนช์ด้านความสอดคล้องและการเซ็นใน Delphi ด้วย PDF Library for Delphi ที่ PFX ที่สร้างแบบนี้ขับเคลื่อนครึ่งการเซ็นของ pipeline ที่รัน PDF/A preflight และ audit ByteRange ด้วย การเซ็นเป็นแค่ครึ่งหนึ่งของสิ่งที่อยู่รอบใบรับรอง และอีกครึ่งคือจุดที่ leaf แบบเซ็นด้วยตัวเองควรล้มเหลวพอดี การเซ็นและตรวจสอบ PAdES ใน Delphi ด้วย PDF Library for Delphi ครอบคลุมการตรวจสอบ chain-of-trust ที่ validator ความสอดคล้องรัน และ validator ที่เดิน chain กลับไปยัง root ที่เชื่อถือได้ไม่มีเหตุผลที่จะเชื่อใบรับรองที่ฟังก์ชันนี้สร้างขึ้นมาเมื่อห้านาทีก่อนจากความว่างเปล่าเลย

PLCreateSelfSignedCertificate เป็นหนึ่งฟังก์ชันในบรรดา API ด้านใบรับรองและการเซ็นในไลบรารี PDF PDF Library for Delphi สำหรับ Delphi และ C++Builder และมันมีอยู่เพื่อช่องว่างพอดีที่อธิบายตรงนี้ คือการทดสอบการเซ็นที่ต้องการคู่คีย์จริงเบื้องหลังมัน และไม่มีอะไรภายนอกให้ต้องสร้างมันเลย