signatureValue แบบ ECDSA ภายใน CMS container คือ DER SEQUENCE { INTEGER r, INTEGER s } ฟังก์ชัน Windows CNG อย่าง BCryptVerifySignature ไม่ยอมรับทั้งสองรูปแบบนั้นเลย มันต้องการ IEEE P1363 แบบความกว้างคงที่ r || s ที่ไม่มี tag และไม่มีความยาวกำกับ HotPDF ซึ่งเป็น native VCL PDF component สำหรับ Delphi และ C++Builder แปลงระหว่างสองรูปแบบนี้ภายใต้กฎ DER ที่เข้มงวดก่อนนำเข้ากุญแจ
ความล้มเหลวที่วิธีนี้ป้องกันไว้เป็นแบบเฉพาะเจาะจงและน่าหมดกำลังใจ Acrobat เปิดเอกสารแล้วแสดงเครื่องหมายถูกสีเขียว แต่ verifier ของคุณเองที่ไล่ byte เดียวกันกลับคืนค่าไม่ถูกต้อง หรือ CNG คืน STATUS_INVALID_SIGNATURE กลับมาโดยไม่มีคำอธิบายเพิ่มเติม ไม่มีอะไรผิดปกติกับลายเซ็นเลย สิ่งที่ผิดคือ ASN.1 ประมาณเจ็ดสิบ byte ถูกส่งไปให้ API ที่คาดหวัง integer ดิบหกสิบสี่ byte และความไม่ตรงกันนี้มองไม่เห็นเลยนอกจากคุณจะรู้ว่าต้องมองหามัน
ทำไม BCryptVerifySignature ถึงปฏิเสธลายเซ็น ECDSA ที่ถูกต้อง
เพราะทั้งสองฝั่งของการเรียกใช้พูดกันคนละ encoding ของลายเซ็น และไม่มีฝ่ายไหนประกาศมันเลย ISO 32000-1 §12.8 บอกว่า signature dictionary บรรทุก CMS blob ไว้ใน /Contents ส่วน RFC 5652 §5.3 บอกว่า signatureValue ใน SignerInfo แต่ละตัวเป็น OCTET STRING ที่เนื้อหาข้างในเป็นไปตามที่อัลกอริทึมลายเซ็นกำหนด สำหรับ ECDSA เนื้อหานั้นคือโครงสร้าง DER ของ SEC 1 คือ SEQUENCE ที่บรรจุ INTEGER สองตัว มันมีความยาวไม่คงที่โดยการออกแบบ เพราะ r กับ s เป็น integer และ DER ตัด leading zero octet ออกจาก integer
IEEE P1363 มองต่างมุมกันโดยสิ้นเชิง มันนิยามลายเซ็นเป็นการต่อพิกัดทั้งสองเข้าด้วยกัน โดยแต่ละตัวถูกเติมศูนย์ทางซ้ายให้พอดีกับความกว้างเป็น byte ของฟิลด์เส้นโค้งพอดี ลายเซ็น P-256 จึงมีความยาว 64 byte เสมอ ในขณะที่ DER encoding ของลายเซ็นเดียวกันมักจะอยู่ที่ 70 หรือ 71 byte และอาจอยู่ระหว่างประมาณ 8 ถึง 72 byte ก็ได้ ถ้าส่งรูปแบบ DER ให้ BCryptVerifySignature ตรง ๆ แค่การตรวจสอบความยาวก็ทำให้การเรียกล้มเหลวทันที นี่คือเหตุผลที่ HotPDF ทำการ normalise ก่อนตรวจสอบ ไม่ใช่หลังจากนั้น
uses
HPDFECDSA;
// Converts a CMS signatureValue into the fixed-width form CNG expects.
// ARaw comes back as 64 bytes for P-256, 96 for P-384, 132 for P-521.
function ToP1363(const ADerSig: TBytes; ACurve: THPDFECDSACurve;
out ARaw: TBytes): Boolean;
begin
Result := HPDFECDSANormalizeSignature(ADerSig, ACurve, eseDER, ARaw);
end;
กฎ DER ที่ตัวแยกวิเคราะห์ลายเซ็นต้องไม่ผ่อนปรน
ทุกการปฏิเสธที่ระบุไว้ที่นี่คือการปฏิเสธที่ HotPDF ทำโดยตั้งใจ และแต่ละข้อก็ปิด path ที่ตัวแยกวิเคราะห์ที่ยอมง่ายจะปล่อยไว้เปิด ความยั่วยวนตอนเขียนตัวแปลงคือการหา node INTEGER สองตัว คัดลอกเนื้อหาแล้วเดินหน้าต่อ วิธีนั้นใช้ได้กับ input ที่ถูกต้องสมบูรณ์แต่ก็ยอมรับตระกูลของการ re-encode ที่ malleable ต่อ input ที่ประสงค์ร้ายไปด้วยอย่างเงียบ ๆ ดังนั้น HPDFECDSANormalizeSignature จึงปฏิเสธ integer ที่เป็นลบ หมายถึง r หรือ s ใดก็ตามที่ octet แรกของเนื้อหามี high bit ตั้งไว้ เพราะ ECDSA scalar ที่ถูกต้องต้องเป็นบวกเสมอ มันปฏิเสธค่าที่เป็นศูนย์ทั้งหมด เพราะ r = 0 หรือ s = 0 ไม่มีทางเป็นลายเซ็นที่ถูกต้องได้เลย มันปฏิเสธ leading zero octet ที่ซ้ำซ้อน เพราะ X.690 §8.3 อนุญาตให้มีได้แค่หนึ่งตัวเท่านั้น และเฉพาะเมื่อ octet ถัดไปจะถูกอ่านเป็นค่าลบถ้าไม่มีมันเท่านั้น ดังนั้น 00 ตามด้วย octet ที่ต่ำกว่า 0x80 คือการ re-encode ไม่ใช่ลายเซ็น มันปฏิเสธ length header ที่ไม่กระชับที่สุด เพราะ X.690 §10.1 กำหนดให้ใช้รูปแบบ definite ที่เข้ารหัสด้วยจำนวน octet น้อยที่สุด และ length แบบ long-form ที่สามารถเป็น short-form ได้คือ byte string คนละตัวที่บรรทุกความหมายเดียวกัน มันปฏิเสธ integer ที่กว้างกว่าขนาดพิกัดของเส้นโค้ง เพราะค่านั้นไม่มีทางเป็น field element ได้ และมันปฏิเสธ node ใด ๆ ที่ตามหลัง s รวมถึง SEQUENCE ชั้นนอกที่ความยาวรวมไม่เท่ากับความยาวของ blob ทั้งหมด
สองข้อสุดท้ายสำคัญกว่าที่เห็น byte ที่ตามหลัง SEQUENCE คือกลเม็ด signature-malleability แบบคลาสสิก ต่อขยะเข้าไป แล้ว verifier ที่ผ่อนปรนก็ยังบอกว่าถูกต้อง ในขณะที่ byte string ที่มันตรวจสอบไม่ใช่ byte string ที่ถูกเซ็นจริง ๆ สัญชาตญาณเดียวกันนี้ขับเคลื่อนการทำ ASN.1 length hardening ที่อธิบายไว้ใน บันทึกเรื่องการ parse PKCS#12 และก็เป็นสัญชาตญาณเดียวกันนี้ที่นี่ด้วย ใน verification path โครงสร้างที่ถูกยอมรับทั้งที่ signer ที่สอดคล้องกับสเปกไม่เคยสร้างมันขึ้นมาเลยคือข้อบกพร่อง ไม่ใช่ความสุภาพ
ความกว้างของพิกัดเป็นของเส้นโค้ง ไม่ใช่ของลายเซ็น
HotPDF ได้ความกว้าง output มาจาก OID ของเส้นโค้งที่ถูกตั้งชื่อไว้ ไม่เคยมาจากความยาวของ DER ที่เพิ่ง parse เสร็จ นี่คือครึ่งหลังของการแปลง และเป็นครึ่งที่ทำผิดพลาดแบบแนบเนียนได้ง่าย RFC 5480 §2.1.1 ระบุเส้นโค้งไว้ในพารามิเตอร์ SubjectPublicKeyInfo ของใบรับรอง และ HPDFECDSACurveFromOID แมป OID สามตัวที่ HotPDF รองรับ คือ 1.2.840.10045.3.1.7 สำหรับ P-256, 1.3.132.0.34 สำหรับ P-384 และ 1.3.132.0.35 สำหรับ P-521 จากนั้น HPDFECDSACoordinateSize คืนค่า 32, 48 หรือ 66 byte และ buffer ของ P1363 ก็เป็นสองเท่าของนั้น คือ 64, 96 หรือ 132 byte แต่ละ integer ที่ decode แล้วจะถูกจัดชิดขวาเข้าไปในครึ่งของมัน ดังนั้น r ที่สั้นจะถูกเติมศูนย์ทางซ้ายแทนที่จะเลื่อน P-521 คือตัวที่ทำให้คนพลาดบ่อย เพราะ 521 บิตคือ 65.125 byte แล้วปัดขึ้นเป็น 66 ทำให้ได้ลายเซ็น 132 byte ซึ่งไม่มีสัญชาตญาณเรื่องเลขยกกำลังสองใดจะทายถูก กุญแจสาธารณะเดินทางมาด้วยกันในรูป EC point แบบไม่บีบอัดตาม RFC 5480 §2.2 คือ 0x04 ตามด้วย X และ Y ดังนั้น HotPDF จึงตรวจสอบว่ามันมีความยาวพอดี 1 + 2 * CoordinateSize byte และขึ้นต้นด้วย 0x04 ก่อนที่จะแตะ CNG
var
Digest, SigDER, PublicPoint: TBytes;
Curve: THPDFECDSACurve;
Res: THPDFECDSAVerifyResult;
begin
// secp256r1, taken from the certificate SubjectPublicKeyInfo parameters
Curve := HPDFECDSACurveFromOID('1.2.840.10045.3.1.7');
// PublicPoint must be $04 || X || Y, so 1 + 2 * 32 = 65 bytes for P-256
Res := HPDFECDSAVerifyDigest(Digest, SigDER, PublicPoint, Curve, eseDER);
case Res of
evrValid:
Memo1.Lines.Add('signature verifies');
evrInvalid:
Memo1.Lines.Add('signature does not match the digest');
evrMalformed:
Memo1.Lines.Add('DER encoding or public point rejected');
evrUnsupported:
Memo1.Lines.Add('curve or algorithm not supported here');
evrProviderUnavailable:
Memo1.Lines.Add('bcrypt.dll or the curve provider is missing');
evrProviderError:
Memo1.Lines.Add('CNG returned an unexpected status');
end;
end;
สังเกตพารามิเตอร์ตัวสุดท้าย HPDFECDSAVerifyDigest ยังรับ eseP1363 ได้ด้วยสำหรับผู้เรียกที่มีลายเซ็นแบบความกว้างคงที่อยู่แล้ว มาจาก hardware token หรือบริการเซ็นระยะไกลที่คืนค่า r || s ดิบ ๆ path นั้นก็ยังบังคับตรวจสอบความยาวและค่าไม่เป็นศูนย์ของทั้งสองครึ่งอยู่ดี ดังนั้น buffer ที่มีขนาดถูกต้องแต่เต็มไปด้วยศูนย์ก็จะถูกปฏิเสธ ไม่ใช่ปล่อยผ่านไปให้ provider
ทำไมชื่ออัลกอริทึม ECDSA แบบทั่วไปถึงล้มเหลวบน Windows รุ่นเก่า
เพราะชื่อแบบทั่วไปนั้นใหม่กว่าฐาน deployment ที่คุณกำลังส่งไปใช้งาน CNG เปิดให้ใช้ตัวระบุอัลกอริทึม ECDSA ที่อนุมานเส้นโค้งจากกุญแจที่นำเข้ามา และมันคือวิธีเขียนโค้ดนี้ที่สะอาดที่สุด แต่ BCryptOpenAlgorithmProvider รับประกันแค่ว่าจะ resolve มันได้บน Windows รุ่นใหม่กว่าเท่านั้น บนเครื่องรุ่นเก่ากว่า การเปิดจะล้มเหลว handle ของ provider ยังคงเป็น nil และการตรวจสอบ ECDSA ทุกครั้งในแอปพลิเคชันของคุณจะรายงานว่าไม่รองรับ ทั้งที่ลายเซ็นนั้นถูกต้องสมบูรณ์แบบ HotPDF หลีกเลี่ยงหน้าผานี้ด้วยการเปิดตัวระบุเฉพาะแต่ละเส้นโค้งแทน มันจะ resolve ECDSA_P256, ECDSA_P384 และ ECDSA_P521 ครั้งเดียว แคช provider handle ไว้หนึ่งตัวต่อเส้นโค้ง แล้วปิดมันตอน unit finalization การตรวจสอบแต่ละครั้งหลังจากนั้นจะทำแค่งานที่ถูกเท่านั้น คือนำเข้ากุญแจสาธารณะชั่วคราวจาก ECCPUBLICBLOB เรียก BCryptVerifySignature แล้วทำลายกุญแจทิ้ง ไม่มี LoadLibrary ซ้ำ ไม่มี GetProcAddress ซ้ำ ไม่มีการเปิดและปิด provider ต่อลายเซ็นแต่ละครั้ง การตรวจสอบเอกสารเป็น batch หลายร้อยฉบับรู้สึกถึงความแตกต่างนี้ได้ชัดเจน เช่นเดียวกับ service process ที่ไม่อย่างนั้นจะต้องหมุนเวียน provider handle ภายใต้ภาระงานหนัก
รหัสผลลัพธ์ยังคงซื่อสัตย์กับความแตกต่างนี้ evrProviderUnavailable หมายความว่าเครื่องไม่สามารถให้ provider แก่ HotPDF ได้ ส่วน evrInvalid หมายความว่า CNG ตอบกลับมาด้วย STATUS_INVALID_SIGNATURE การยุบสองอย่างนี้ให้เป็นความล้มเหลวเดียวคือวิธีที่ปัญหา deployment ถูกรายงานผิดว่าเป็นเอกสารปลอม การแยกระหว่างความล้มเหลวของสภาพแวดล้อมกับความล้มเหลวทาง cryptographic แบบเดียวกันนี้ก็ปรากฏอยู่ตลอดใน CNG และ CAPI handling ฝั่งการเซ็น ซึ่งครอบคลุมไว้ใน บทความเรื่องการเซ็นด้วย certificate store และลำดับ byte
ใครเป็นคนเซ็นเอกสารนี้ SignerIdentifier คือสองสิ่งที่ต่างกัน
RFC 5652 §5.3 กำหนดให้ SignerIdentifier เป็น CHOICE และ verifier ที่จัดการได้แค่แขนงเดียวจะตรวจสอบกับกุญแจผิดโดยไม่มีการเตือนใด ๆ แขนงแรกคือ issuerAndSerialNumber ซึ่งเป็น SEQUENCE ที่บรรจุ issuer Name แบบ DER ดิบและ serial INTEGER การจับคู่มันคือการเปรียบเทียบ byte กับใบรับรองแต่ละใบใน set certificates ของ CMS แขนงที่สองคือ [0] subjectKeyIdentifier ซึ่งเป็น OCTET STRING ที่ tag แบบ implicit และการจับคู่มันต้องขุดลึกเข้าไปในใบรับรอง ไม่ใช่แค่เปรียบเทียบฟิลด์ header ของมัน
การขุดนี้มี layer ที่ทำให้คนแปลกใจ key identifier อยู่ใน X.509v3 extension ดังนั้น HotPDF จึงไล่ผ่านฟิลด์ extension [3] ของ tbsCertificate หา extension ที่มี OID เป็น 2.5.29.14 ข้าม BOOLEAN critical ที่เป็นทางเลือกไป แล้วนำ OCTET STRING ของ extnValue มา octet string นั้นไม่ใช่ตัว identifier เอง ตาม RFC 5280 §4.2.1.2 เนื้อหาข้างในเป็น DER อีกชั้นหนึ่ง และประเภท KeyIdentifier ก็เป็น OCTET STRING อีกตัวหนึ่ง ดังนั้นคุณต้อง parse อีกครั้งเพื่อให้ถึง byte จริง หยุดเร็วเกินไปหนึ่งชั้นแล้วคุณจะเปรียบเทียบ wrapper 22 byte กับ identifier 20 byte ไม่มีใบรับรองใดจับคู่ได้เลย และ verifier ก็จะถอยไปใช้ heuristic ใดก็ตามที่คุณเขียนไว้ถัดไป ซึ่งนั่นคืออันตรายที่แท้จริง การหยิบใบรับรองแรกใน set มาใช้เป็นทางลัดที่ยั่วยวน และมันผิดทุกครั้งที่ CMS บรรทุก chain มาด้วย ซึ่งเป็นกรณีส่วนใหญ่ เพราะ leaf ไม่จำเป็นต้องมาก่อน HotPDF จะยอมรับใบรับรองที่ไม่ได้จับคู่ก็ต่อเมื่อ container มีอยู่แค่ใบเดียวเท่านั้น เมื่อมีใบรับรองหลายใบอยู่ การจับคู่ SignerIdentifier อย่างแม่นยำเป็นสิ่งบังคับ การตรวจสอบ digest กับกุญแจสาธารณะของ intermediate CA ไม่ได้สร้าง error ที่เป็นมิตร มันสร้างความมั่นใจแบบผิด ๆ ว่าไม่ถูกต้องบนเอกสารที่ไม่มีอะไรผิดปกติเลย
var
Pdf: THotPDF;
Info: THPDFSignatureInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('signed.pdf') > 0 then
for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
if Pdf.VerifyLoadedSignatureEx(I, Info) = svValid then
Memo1.Lines.Add(Format('%s: %s %s over %s, signer %s',
[String(Info.FieldName), String(Info.PublicKeyAlgorithm),
String(Info.CurveName), String(Info.HashAlgorithm),
String(Info.SignerName)]))
else
Memo1.Lines.Add(Format('%s: not valid', [String(Info.FieldName)]));
finally
Pdf.Free;
end;
end;
THPDFSignatureInfo.CurveName รายงาน P-256, P-384 หรือ P-521 เพื่อให้ audit log บันทึกไว้ว่าเส้นโค้งใดถูกใช้งานจริง แทนที่จะบันทึกแค่คำว่า ECDSA เฉย ๆ ระบบเบื้องหลังในระดับเอกสารที่เกี่ยวกับการเรียกนี้ โดยเฉพาะวิธีที่ segment ของ /ByteRange ถูก hash และเหตุผลที่ digest ต้องคำนวณจากไฟล์แทนที่จะเป็น object tree ที่ parse แล้ว เป็นหัวข้อของ บทความคู่กันเรื่องการตรวจสอบลายเซ็นดิจิทัลของ PDF
สิ่งนี้ไม่ได้ให้อะไรกับคุณ
ผลลัพธ์สีเขียวจาก HPDFECDSAVerifyDigest ตอบคำถามได้แค่ข้อเดียวเท่านั้น: byte เหล่านี้ถูกเซ็นโดยกุญแจส่วนตัวที่ตรงกับกุญแจสาธารณะนี้ มันไม่ได้บอกอะไรเลยว่ากุญแจนั้นเป็นของใครที่คุณควรไว้ใจ การสร้าง chain ไปยัง trust anchor การเพิกถอนผ่าน CRL หรือ OCSP และการตรวจสอบ policy เป็นงานแยกต่างหาก และผลิตภัณฑ์ใดก็ตามที่รายงานว่าลายเซ็นถูกต้องโดยไม่มีสิ่งเหล่านั้นกำลังรายงานน้อยกว่าที่ผู้ใช้คิดไว้ วันหมดอายุของใบรับรองถูกแสดงแยกต่างหากใน THPDFSignatureInfo ด้วยเหตุผลนี้เป๊ะ ๆ นั่นคือลายเซ็นอาจตรวจสอบผ่านทาง cryptographic ได้ในขณะที่ใบรับรองที่สร้างมันหมดอายุไปแล้วสองปี การรองรับเส้นโค้งก็จงใจให้แคบด้วย เส้นโค้ง NIST prime สามเส้นถูกจัดการไว้ และลายเซ็นบนเส้นโค้งอื่นใดจะคืนค่าไม่รองรับแทนที่จะเดา CNG path ก็ใช้ได้เฉพาะบน Windows เท่านั้น ซึ่งเป็นการแลกที่ถูกต้องสำหรับ VCL component แต่ควรระบุไว้ก่อนที่คุณจะวางแผน service แบบข้ามแพลตฟอร์มรอบ ๆ มัน และความเข้มงวดนี้ปรับแต่งไม่ได้ ไม่มีโหมดผ่อนปรนที่ยอมรับความยาว DER ที่ไม่กระชับที่สุดเพราะ signer รุ่นเก่าบางตัวสร้างมันออกมา ถ้าคุณเจอไฟล์แบบนั้นในการใช้งานจริง การตอบสนองที่ซื่อตรงคือบันทึกมันไว้แล้วไปตามหาผู้สร้าง ไม่ใช่ขยายตัวแยกวิเคราะห์จนไฟล์นั้นผ่าน
Path การตรวจสอบ ECDSA ที่อธิบายไว้ที่นี่มาพร้อมกับ HotPDF Component มาตรฐานสำหรับ Delphi และ C++Builder ควบคู่ไปกับ path RSA PKCS#1 v1.5 และ RSA-PSS และรายการข้อมูลลายเซ็นแบบเต็มรูปแบบ หน้าผลิตภัณฑ์มีเอกสารอ้างอิงลายเซ็นดิจิทัลแบบครบถ้วน