PDFium Component เวอร์ชัน 3.114.20 แก้การ encode RSASSA-PSS-params ใน backend การเซ็น PAdES ทั้งสามตัว ได้แก่ Windows CNG, macOS Keychain และ PKCS#11 RFC 4055 §3.1 ให้ explicit context-specific tag แก่ทุก field ของ RSASSA-PSS-params ตั้งแต่ [0] ถึง [3] และ backend เหล่านั้นก็ emit saltLength เป็น universal INTEGER เปล่า ๆ พร้อมกับเขียน trailerField ที่เท่ากับค่า default ของมันออกมาด้วย byte ของลายเซ็นถูกต้องมาตลอด แต่ AlgorithmIdentifier ที่บรรยายมันไม่ถูกต้อง และแค่นั้นก็พอให้ verifier ปฏิเสธลายเซ็นได้แล้ว
ส่วนที่น่าหงุดหงิดคือที่ที่ bug ซ่อนอยู่ ลายเซ็น CMS มีสองครึ่ง คือการดำเนินการเชิงรหัสลับ กับ ASN.1 ที่บอก verifier ว่าการดำเนินการนั้นทำอย่างไร ถ้าครึ่งแรกถูกและครึ่งหลังผิด ผลลัพธ์ก็คือเอกสารที่เครื่องมือที่ทำตามสเปกตัวไหนก็แยกไม่ออกจากของปลอม บทความนี้พูดถึงแค่ครึ่งหลังนั้น: ว่า RSASSA-PSS-params ต้องถูก tag อย่างไร, backend สามตัวทำผิดแบบเดียวกันได้อย่างไร และ DER ที่แก้แล้วหน้าตาเป็นอย่างไรในแง่ของ TDerWriter
ทำไม verifier ถึงปฏิเสธลายเซ็น RSASSA-PSS ที่ byte ถูกต้อง
เพราะ RSASSA-PSS เป็นแผน RSA แบบเดียวที่ verifier กู้พารามิเตอร์กลับมาจากตัวลายเซ็นเองไม่ได้ PKCS#1 v1.5 padding ถูกกำหนดครบถ้วนโดย OID sha256WithRSAEncryption พารามิเตอร์ของมันจึงเป็น NULL เปล่า ๆ และไม่มีอะไรให้ทำผิด PSS มีพารามิเตอร์คือฟังก์ชันแฮช, mask generation function ที่มีแฮชของตัวเอง และความยาว salt โดย RFC 8017 §A.2.3 เปิดทั้งสามอย่างไว้ ผู้เซ็นเป็นคนเลือก AlgorithmIdentifier เป็นตัวพามัน และ verifier ต้องสร้างมันขึ้นมาให้เหมือนเป๊ะก่อนที่ EMSA-PSS-VERIFY จะเริ่มได้ด้วยซ้ำ
ดังนั้นเมื่อ PDFium Component เซ็นด้วย SHA-256, MGF1 บน SHA-256 และ salt 32 byte ข้อเท็จจริงสามข้อนั้นต้องรอดผ่านการ encode DER ฝั่งนี้และการ decode DER บน implementation อีกตัว บล็อกพารามิเตอร์ที่ verifier parse ไม่ได้จะจบการตรวจสอบก่อนที่การยกกำลังมอดุลัสจะเกิดขึ้นด้วยซ้ำ บล็อกที่มัน parse ได้ต่างออกไปยิ่งแย่กว่า เพราะ RFC 4055 §3.1 ให้ค่า default ของ saltLength ไว้ที่ 20 decoder ที่ข้าม field ที่มันไม่รู้จักจะไปตกบนค่า default นั้น รัน EMSA-PSS-VERIFY ด้วย salt 20 byte กับลายเซ็นที่คำนวณด้วย 32 แล้วรายงานว่าลายเซ็นไม่ดีโดยไม่บอกใบ้ว่าปัญหาอยู่ที่ metadata ไม่ใช่ที่คีย์ ผลลัพธ์ทั้งสองแบบคือสิ่งที่การ encode ของ 3.114.19 ผลิตออกมา ขึ้นกับว่า verifier เข้มงวดแค่ไหน และไม่มีทางไหนชี้ไปที่ AlgorithmIdentifier
RFC 4055 §3.1 กำหนดอะไรกับ RSASSA-PSS-params จริง ๆ
RFC 4055 §3.1 นิยาม RSASSA-PSS-params เป็น SEQUENCE ของสี่ field โดยแต่ละ field พก explicit context-specific tag และค่า DEFAULT:
// RSASSA-PSS-params ::= SEQUENCE {
// hashAlgorithm [0] HashAlgorithm DEFAULT sha1,
// maskGenAlgorithm [1] MaskGenAlgorithm DEFAULT mgf1SHA1,
// saltLength [2] INTEGER DEFAULT 20,
// trailerField [3] TrailerField DEFAULT trailerFieldBC
// }
explicit tagging ใน DER หมายความว่าแต่ละ field ถูกห่อใน TLV แบบ constructed context-specific โดย A0 สำหรับ [0], A1 สำหรับ [1], A2 สำหรับ [2] และ A3 สำหรับ [3] โดยมีการ encode แบบ universal ของค่าซ้อนอยู่ข้างใน ทุก field ถูก tag ก็เพราะทุก field เป็นออปชันผ่านค่า default ของตัวเอง ถ้าไม่มี tag decoder ก็บอกไม่ได้ว่า SEQUENCE ที่พก AlgorithmIdentifier ตัวเดียวกำลังพา hashAlgorithm หรือ maskGenAlgorithm เพราะทั้งคู่เป็นชนิด SEQUENCE เหมือนกัน; พอมี tag หมายเลข tag ก็ระบุ field ได้ไม่ว่าข้างเคียงตัวไหนจะปรากฏอยู่ ค่าที่ PDFium Component emit ทำตาม profile ใน ETSI TS 119 312 §7 คือ SHA-256, MGF1 กับ SHA-256 และ salt เท่ากับความยาว digest และมันสะท้อนสิ่งที่การเรียกเซ็นของแต่ละแพลตฟอร์มได้รับมาอย่างตรงเป๊ะ: BCRYPT_PSS_PADDING_INFO ที่มี cbSalt 32 สำหรับ NCryptSignHash, CK_RSA_PKCS_PSS_PARAMS ที่มี sLen 32 สำหรับกลไก PKCS#11 และอัลกอริทึม PSS ที่เซ็นด้วย digest SHA-256 ใน Security framework
backend สามตัวทำผิดแบบเดียวกันได้อย่างไร
การ encode ใน 3.114.19 tag สอง field แรกและปล่อยสอง field หลังไว้เปล่า ๆ เหมือนกันเป๊ะใน TWinCmsSigner, TKeychainCmsSigner และ TPkcs11CmsSigner ความสมมาตรนั้นไม่ใช่เรื่องบังเอิญ: ทั้งสามตัว implement อินเทอร์เฟซ ICmsSigner จาก FPdfCms.pas และบอดี้ GetSignatureAlgorithmParams ของพวกมันถูกเขียนตามเทมเพลตเดียว เทมเพลตนั้นอ่านได้แบบนี้:
// ก่อน 3.114.20: [0] กับ [1] ถูก tag, [2] กับ [3] ไม่
Result := W.Sequence(Concat4(
W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
W.IntegerOf(32), // INTEGER เปล่า ๆ ตรงที่ต้องเป็น [2] EXPLICIT
W.IntegerOf(1))); // trailerField ซึ่งเท่ากับ DEFAULT ต้องไม่อยู่
decoder ที่เดินผ่าน SEQUENCE นั้นจะเห็น A0 อ่านอัลกอริทึมแฮช เห็น A1 อ่าน mask generation function แล้วก็เจอ 02 01 20 นั่นคือ universal INTEGER และ RSASSA-PSS-params ไม่มีสมาชิกที่เป็น INTEGER ไม่ tag อยู่ตรงไหนเลย decoder ที่เข้มจะหยุดตรงนั้น ตัวที่ผ่อนปรนจะข้าม element ที่ไม่รู้จัก ไม่เจอ A2 เลย ให้ค่า default ของ saltLength เป็น 20 แล้วก็เจอ INTEGER หลงอีกตัวคือ 02 01 01 และเจอปัญหาเดิมอีกครั้ง ไม่มีเส้นทางไหนไปถึง salt 32 byte ได้ เทมเพลตที่ใช้ร่วมกันมีประสิทธิภาพเมื่อมันถูกต้อง และเป็นวิธีที่ได้ผลพอ ๆ กันในการทำผิดสามครั้งเมื่อมันไม่ถูก นั่นคือเหตุผลที่การแก้ไปลงในทั้งสามยูนิตในคอมมิตเดียว และเหตุผลที่บอดี้ของทั้งสามเมธอดยังมีโครงสร้างเหมือนกันเป๊ะหลังจากนั้น backend ในอนาคตควรคัดลอกบล็อกจากตัวใดตัวหนึ่งในนี้ แทนที่จะอนุมานขึ้นมาใหม่ เพราะการอนุมานนั่นแหละคือที่ที่ความผิดพลาดเกิดขึ้น
ทำไม trailerField ถึงถูกตัดออกแทนที่จะ tag เป็น [3]
เพราะ X.690 §11.5 บอกว่า DER encoder ต้องไม่ encode component ที่ค่าของมันเท่ากับ DEFAULT ของมัน และ trailerField มี DEFAULT เป็น trailerFieldBC ซึ่งก็คือจำนวนเต็ม 1 การแก้ที่ดูเหมือนตรงไปตรงมากับโค้ดเก่า คือแทน W.IntegerOf(1) เปล่า ๆ ด้วย W.ContextSpecific(3, W.IntegerOf(1), True) จะให้บล็อกที่ decoder แบบ BER ที่ผ่อนปรนยอมรับ แต่ decoder แบบ DER ที่เข้มมีสิทธิ์ปฏิเสธ ค่าไม่ผิด การมีอยู่ของมันต่างหากที่ผิด กฎเดียวกันนี้คือเหตุผลที่อีกสาม field ต้องอยู่: SHA-256 ไม่ใช่ค่า default sha1, MGF1 กับ SHA-256 ไม่ใช่ค่า default mgf1SHA1 และ 32 ไม่ใช่ค่า default 20 ถ้า backend เซ็นด้วย SHA-1 กับ salt 20 byte RFC 4055 §3.1 จะยุบพารามิเตอร์ลงเหลือ SEQUENCE ว่าง ๆ คือ 30 00 และ SEQUENCE ว่างนั้นต่างหาก ไม่ใช่ NULL ที่ verifier คาดหวัง PDFium Component ไม่เคย emit รูปร่างนั้นเพราะไม่เคยเซ็นด้วยค่าเหล่านั้น แต่มันคือเคสที่จะจับคนที่คิดว่า "ไม่มีพารามิเตอร์" สะกดเป็น 05 00 เสมอ
นี่คือความต่างระหว่าง DER กับ BER ที่สำคัญกับลายเซ็นโดยเฉพาะ BER ยอมให้ encoder ใส่ component ที่มีค่าเป็น default ได้ ส่วน DER ห้าม เพราะ DER มีอยู่เพื่อให้หนึ่งค่ามีการ encode เพียงแบบเดียว และลายเซ็นบนโครงสร้างที่มีการ encode ที่ถูกกฎหมายได้สองแบบก็เป็นลายเซ็นที่เถียงกันได้ ทุกอย่างภายใน CMS signedAttrs เป็น DER ด้วยเหตุผลนั้น และบล็อกพารามิเตอร์ก็เดินทางอยู่ใน signedAttrs ผ่าน attribute cmsAlgorithmProtection เช่นเดียวกับใน signatureAlgorithm ชั้นนอก มันจึงไม่ได้รับการยกเว้น
การ encode ด้วย TDerWriter ที่แก้แล้ว
ตอนนี้ PDFium Component สร้างพารามิเตอร์ด้วยการเรียก TDerWriter.ContextSpecific จาก FPdfAsn1.pas สามครั้ง หนึ่งครั้งต่อหนึ่ง field ที่ไม่ใช่ค่า default โดยแต่ละครั้งตั้งอาร์กิวเมนต์ Constructed เป็น True เพื่อสร้าง wrapper ของ explicit tag และไม่มีบรรทัดใดเลยสำหรับ trailer field นี่คือบอดี้ของ TWinCmsSigner.GetSignatureAlgorithmParams ที่เขียน OID ออกมาตรง ๆ; ยูนิตของ Keychain กับ PKCS#11 สะกดค่าเดียวกันเป็น OID_SHA256, OID_MGF1 และ OID_RSASSA_PSS:
function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
W: TDerWriter;
begin
if FPaddingScheme = psRsaPss then
begin
W := TDerWriter.Create;
try
// RFC 4055 3.1 tag ทั้งสี่ field saltLength คือ [2]; INTEGER เปล่า ๆ
// ตรงนี้จะถูกอ่านเป็นจุดเริ่มของอีก field หนึ่ง trailerField
// คือ [3] ที่มี DEFAULT 1 และ X.690 11.5 ห้าม encode ค่าที่
// เท่ากับ default มันจึงถูกตัดออกทั้งหมด
Result := W.Sequence(Concat3(
W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
W.ContextSpecific(1, W.Sequence(ConcatBytes(
W.OID('1.2.840.113549.1.1.8'), // id-mgf1
W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
W.ContextSpecific(2, W.IntegerOf(32), True)));
finally
W.Free;
end;
end
else
Result := nil; // PKCS#1 v1.5 และ ECDSA: AlgIdWithParams เขียน NULL
end;
มีสองรายละเอียดของเครื่องจักรโดยรอบที่สำคัญ TDerWriter.AlgId ผลิต AlgorithmIdentifier ที่มีพารามิเตอร์เป็น NULL ซึ่งเป็นสิ่งที่ RFC 4055 §2.1 บอก encoder ให้สร้างสำหรับ hashAlgorithm ที่ซ้อนอยู่และสำหรับแฮชชั้นในของ MGF1 และ CMS builder ใน FPdfCms.pas จับคู่ OID ของลายเซ็นกับ byte เหล่านี้ผ่าน TDerWriter.AlgIdWithParams ซึ่งแทนที่ด้วย NULL เมื่อพารามิเตอร์เป็น nil; นั่นคือเหตุผลที่ psRsaPkcs1v15 กับ psEcdsa คืน nil ตรง ๆ และไม่เคยได้รับผลกระทบ และเหตุผลที่ 1.2.840.113549.1.1.10 ซึ่งคือ id-RSASSA-PSS เป็น OID ลายเซ็นตัวเดียวในสามตัวที่พกบล็อกพารามิเตอร์จริง byte ที่ได้สำหรับโปรไฟล์ SHA-256 นั้นตายตัวและสั้นพอจะตรวจด้วยตา: SEQUENCE ชั้นนอก 30 34 ที่ห่อ A0 0F รอบ AlgorithmIdentifier ของ SHA-256 ขนาด 15 byte, A1 1C รอบ AlgorithmIdentifier ของ MGF1 ขนาด 28 byte ซึ่งพารามิเตอร์ของตัวมันเองก็คือ AlgorithmIdentifier ของ SHA-256 ตัวเดียวกันนั้น และ A2 03 02 01 20 สำหรับ salt ถ้า dump signatureAlgorithm ของคุณแล้วเห็น 02 01 20 ที่ระดับบนสุดของ SEQUENCE พารามิเตอร์ ไม่ใช่ข้างใน A2 คุณกำลังมองการ encode ของ 3.114.19 อยู่
ทำไมชุดเทสต์ถึงจับ AlgorithmIdentifier ที่ผิดรูปไม่ได้
เพราะเทสต์ของ PAdES ขับ CMS builder ผ่าน signer ปลอมที่รายงาน sha256WithRSAEncryption และคืน nil จาก GetSignatureAlgorithmParams บล็อกพารามิเตอร์ของ PSS จึงไม่เคยถูกสร้างในเทสต์เลยแม้แต่ครั้งเดียว นั่นเป็นการออกแบบที่สมเหตุสมผลสำหรับเทสต์ที่ต้องรันได้โดยไม่มี certificate store, Keychain หรือ token และมันมีจุดบอดที่มีรูปร่างชัดเจน: อะไรที่ backend จริงเท่านั้นผลิตออกมา ก็มีแค่ backend จริงเท่านั้นที่ไปแตะมัน ชั้นที่สองน่าสนใจกว่า PDFium Component ยังวาง AlgorithmIdentifier ของลายเซ็น ซึ่งรวมพารามิเตอร์ไว้ด้วย ไว้ใน signed attribute cmsAlgorithmProtection จาก RFC 6211 และ verifier จะเทียบสำเนานั้นกับ signatureAlgorithm ชั้นนอก สำเนาทั้งสองมาจากการเรียกครั้งเดียวกัน พวกมันจึงตรงกันเป๊ะและการตรวจความสอดคล้องภายในทุกข้อก็ผ่านหมด การ encode นั้นสอดคล้องกับตัวเองและผิด ซึ่งเป็น bug ประเภทที่เทียบโครงสร้างกับตัวเองเท่าไรก็เปิดเผยไม่ได้ และบทเรียนเดียวกันกับโครงสร้างที่ต่างออกไปถูกเล่าไว้ในCMS signedAttrs และการเรียง DER SET OF ที่ SET ถูก hash ในลำดับหนึ่งและถูก emit ในอีกลำดับหนึ่งดูปกติดีจนกระทั่ง verifier จากที่อื่นคำนวณ hash ใหม่
สิ่งที่จับ bug ประเภทนี้ได้คือ decoder ที่ไม่ได้เขียนโดยคนเขียน encoder รันกับ output จริงของ backend จริง เส้นทางการตรวจสอบบน Windows ใน PDFium Component ผ่าน CryptoAPI ไม่ใช่ reader ของไลบรารีเอง และลายเซ็น PSS ที่ถูกปฏิเสธตรงนั้นแหละที่นำกลับมาสู่พารามิเตอร์ ASN.1 ใด ๆ ที่ implementation หนึ่ง emit ออกมาให้ implementation อื่นอ่าน สมควรได้ round trip อย่างน้อยหนึ่งรอบผ่าน decoder ที่มันไม่ได้ควบคุม และยิ่งโครงสร้างมี default กับ tag มากเท่าไร round trip นั้นก็ยิ่งมีค่า
เรื่องนี้อยู่ตรงไหนในภาพรวมของ PSS
การแก้นี้แยกอิสระจากอีกสองจุดที่ PSS พังได้ในลายเซ็น PAdES และการแยกให้พวกมันห่างกันก็ทำให้ดีบักสั้นลง backend บน macOS อาจพบว่าคีย์ตัวหนึ่งหรือระบบที่เก่ากว่าปฏิเสธ PSS แล้วลดระดับลงไปใช้ PKCS#1 v1.5 และ AlgorithmIdentifier ต้องตามการลดระดับนั้นไปด้วย; นั่นเป็นคำถามเรื่อง capability ที่ครอบคลุมอยู่ในการเซ็น PAdES ด้วย identity จาก macOS Keychain ส่วน backend PKCS#11 อาจยื่น CK_RSA_PKCS_PSS_PARAMS ให้ token ที่อ่าน layout ของมันต่างออกไปเพราะความกว้างจำนวนเต็มไม่ตรงกัน; นั่นเป็นคำถามเรื่อง ABI ที่ครอบคลุมอยู่ในCK_ULONG กับกับดักการ packing ของ PKCS#11 บทความนี้พูดถึงความล้มเหลวแบบที่สาม ที่คีย์ยินยอม token คำนวณ byte ถูกต้อง และ DER ที่บรรยายผลลัพธ์ไม่เป็นไปตาม RFC 4055 §3.1
signer ที่ประกาศว่าใช้ PSS รับพันธะที่ signer แบบ v1.5 ไม่เคยมี: ต้องบรรยายพารามิเตอร์ของตัวเองในรูปแบบที่ implementation อื่น decode ออกมาเป็นค่าสามตัวเดียวกัน RFC 4055 §3.1 ตรึง tag ไว้ X.690 §11.5 ตรึงว่า field ไหนปรากฏได้ และ ETSI TS 119 312 §7 ตรึงค่าที่ควรเลือก backend ทั้งสามตัวของคอมโพเนนต์ PDFium สำหรับ Delphiออกมาเป็นซอร์ส บอดี้ GetSignatureAlgorithmParams ข้างบนจึงเป็นสิ่งที่คุณอ่านได้, dump ได้ และเทียบกับ verifier ของคุณเองได้ แทนที่จะต้องเชื่อกันไป