HotPDF validate PDF MAC ตาม ISO/TS 32004 ในระดับรีวิชัน ไม่ใช่ระดับไฟล์ THotPDF.ValidatePDFMACChain เดินไล่ incremental update ทุกรอบจากจุดยึดของห่วงโซ่ไปข้างหน้า และตรวจ MAC แต่ละตัวเทียบกับ stream แบบ read-only ที่จบที่ startxref กับ %%EOF ของรีวิชันนั้นเอง มี MAC ที่ถูกต้องหนึ่งตัวบนรีวิชันใหม่สุดก็ไม่พิสูจน์อะไรเลยเกี่ยวกับรีวิชันที่อยู่ใต้ลงไป
สถานการณ์ที่เป็นแรงบันดาลใจของทั้งหมดนี้มีดังนี้ คุณส่งมอบ PDF ที่เข้ารหัส AES-256 พร้อม PDF MAC บนมัน มีคนเปิดไฟล์ใน hex editor พลิกไบต์หนึ่งตัวภายในรีวิชันแรกที่ MAC คุ้มครองอยู่ แล้วผนวกรีวิชันใหม่เอี่ยมเข้าไปท้ายไฟล์พร้อม MAC ของตัวเองที่สมบูรณ์ตามสเปก ทุกตัว viewer เปิดไฟล์นั้นโดยไม่มีข้อร้องเรียน และตัวตรวจแบบดื้อ ๆ ที่แฮชช่วงไบต์ปัจจุบันเทียบกับ MAC ใน trailer ที่ active อยู่ก็รายงานความสำเร็จ — เพราะ MAC ตัวนั้นแหละถูกต้องจริงกับไบต์ที่มันคลุม ความเสียหายนอนอยู่สองรีวิชันลงไป ในพื้นที่ที่ไม่มีใครกลับไปตรวจอีก
ทำไม MAC ระดับบนสุดที่ถูกต้องจึงไม่พิสูจน์ว่าไฟล์ไม่บุบสักหน่อย
เพราะ PDF MAC คลุมแค่ prefix ไม่ใช่ทั้งเอกสาร การ incremental update เป็นพลเมืองชั้นหนึ่งของฟอร์แมต: การบันทึกทุกครั้งผนวก body ใหม่, ส่วน cross-reference ใหม่ และ trailer ใหม่ ขณะที่ไบต์เก่าคงอยู่ตรงเดิมเป๊ะ ISO/TS 32004 ขี่อยู่บนโมเดลนี้ แต่ละรีวิชันจึงแบก dictionary /AuthCode ของตัวเองไว้ยืนยันไฟล์ในสภาพ ณ ขณะนั้น และการตรวจเฉพาะตัวใหม่สุดทิ้งให้ทุกรีวิชันก่อนหน้าไม่ถูกตรวจเลย HotPDF จึงแยกสองคำถามนี้ออกเป็นสองการเรียก และความต่างระหว่างสองตัวนี้คือทั้งประเด็นของบทความ ValidatePDFMAC ตอบว่า "รีวิชันปัจจุบันเชื่อถือได้หรือไม่" โดยเติมเรกคอร์ด THPDFPDFMACValidationInfo ส่วน ValidatePDFMACChain ตอบว่า "รีวิชันที่ MAC คุ้มครองทุกรอบในไฟล์นี้เชื่อถือได้หรือไม่" โดยเติม THPDFPDFMACChainValidationInfo พร้อมอาร์เรย์รายรีวิชันและเหตุผลความล้มเหลวที่เครื่องอ่านได้ บนไฟล์ที่ถูกแก้แล้วผนวก MAC ใหม่ตามตัวอย่างด้านบน การเรียกแรกคืน True และการเรียกที่สองคืน False ที่รีวิชัน index 1
var
Pdf: THotPDF;
Chain: THPDFPDFMACChainValidationInfo;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
begin
// ความล้มเหลวเป็นหนึ่งใน pmcfRevisionBoundary, pmcfNoPDFMAC,
// pmcfRequiredRevisionMissing, pmcfRevisionInvalid,
// pmcfKDFSaltChanged, pmcfDigestDowngrade, pmcfPermissionDowngrade
Writeln('chain rejected: ', Chain.Message);
Writeln('revision ', Chain.FailureRevisionIndex,
' at xref offset ', Chain.FailureXRefOffset);
Exit;
end;
for I := 0 to High(Chain.Revisions) do
Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
' mac=', Chain.Revisions[I].HasPDFMAC,
' perms=', Chain.Revisions[I].PermissionsAuthenticated);
finally
Pdf.Free;
end;
end;
แต่ละ MAC ถูกตรวจบน stream prefix ของตัวเอง ไม่ใช่ความยาวไฟล์สุดท้าย
บั๊กที่แพงที่สุดในพื้นที่นี้คือการใช้ขนาดไฟล์สุดท้ายเป็นขอบบนเวลาแฮชรีวิชันเก่าซ้ำ ซึ่งพับไบต์ท้ายไฟล์เข้าไปใน digest ทุกตัวยกเว้นตัวใหม่สุด แล้วรายงานว่าไฟล์ที่สมบูรณ์ถูกแก้ไข HotPDF จึงสร้างขึ้นใหม่สำหรับแต่ละรีวิชัน: stream แบบ read-only ที่มีขอบเขตและจบที่ค่า startxref ของรีวิชันนั้นตามด้วย %%EOF ของมัน แล้วแฮชเฉพาะช่วงนั้น การระบุขอบเขตยุ่งกว่าที่เห็น: ข้อความตรงตัว %%EOF ปรากฏได้ใน content stream หรือสตริง ผู้สมมุติถูกยอมรับเฉพาะเมื่อ startxref ที่อยู่ก่อนหน้าทันที parse ออกมาเป็นเลขเท่ากับ offset ของ cross-reference ของส่วนที่กำลังตรวจ โดยมีแค่ช่องว่างคั่นเท่านั้น รีวิชันแล้วดูดกลืนลำดับ end-of-line หลัง marker ไปแค่ชุดเดียว — CR ตัวเดียว, LF ตัวเดียว หรือคู่ CRLF หนึ่งชุด — และไม่เกินนั้น กติกาสุดท้ายนี้กัดจริงในทางปฏิบัติ เพราะ writer ที่ปล่อยบรรทัดว่างเพิ่มมาระหว่างสองรีวิชันได้ผลิตไบต์ที่เป็นของรีวิชันถัดไปเอาไว้ และการกลืนช่องว่างท้ายทั้งหมดเข้ารีวิชันก่อนหน้าเปลี่ยน digest ทั้งสองเงียบ ๆ การนับส่วน cross-reference เดินตามวินัยเดียวกัน: HotPDF เดินส่วน cross-reference จากเก่าไปใหม่แบบครั้งเดียวพอดี เล่นซ้ำรายการ free, direct และ object-stream เพื่อให้ส่วนหลังเขียนทับสถานะของส่วนก่อน ตรงข้ามกับความหมาย first-seen-wins ที่ parser ของ active xref ใช้
ห่วงโซ่ยึดกับจุดไหน และอะไรทำให้มันหัก
รีวิชันแรกที่แบก /AuthCode ที่ถูกต้องคือจุดยึด และ FirstMACRevisionIndex รายงานว่าการคุ้มครองเริ่มตรงไหน ทุกอย่างก่อนหน้าไม่ถูกคุ้มครองโดยโครงสร้าง ซึ่งเป็นเรื่องปกติ ทุกอย่างหลังจากนั้นต้องถูก MAC คุ้มครอง การผนวก incremental update เปล่า ๆ หนึ่งรอบเข้าไฟล์ที่ถูกคุ้มครองจึงล้มด้วย pmcfRequiredRevisionMissing พร้อม index ของรีวิชันผู้กระทำ — การยอมให้มีช่องว่างแปลว่าผู้โจมตีลอกแบบการคุ้มครองออกได้เพียงบันทึกเพิ่มอีกครั้ง ค่ายังมีค่าคงเส้นคงวาอีกสามข้อที่ทำงานครอบห่วงโซ่ทั้งเส้น แต่ละข้อมีรหัสความล้มเหลวของตัวเอง
pmcfKDFSaltChanged—/KDFSaltต้องนิ่งตั้งแต่จุดยึดเป็นต้นไป เพราะเกลือที่หมุนเวียนได้ยอมให้ผู้ปลอมแปลง re-derive คีย์ภายใต้พารามิเตอร์ที่ตัวเองเลือกpmcfDigestDowngrade— ความแข็งแรงของ digest ถูกเทียบกับ MAC ที่ตรวจผ่านแล้วล่าสุด ไม่ใช่รีวิชันก่อนหน้าทันที ห่วงโซ่ที่เริ่มภายใต้โปรไฟล์ Modern ที่ SHA-384 จึงต่อเนื่องต่อด้วย SHA-256 อย่างเงียบ ๆ ไม่ได้pmcfPermissionDowngrade— รีวิชันหนึ่งห้ามเคลียร์ข้อกำหนด PDF MAC ที่รีวิชันก่อนหน้าเคยยืนยันไว้
ผลที่ควรกลืนเข้าไปในสมองคือ MAC ในอดีตถูกตรวจแยกจากกันแม้พวกมันจะไม่ใช่ trailer ที่ active แล้ว นั่นคือเหตุผลที่การโจมตีแบบแก้รีวิชันเก่าแล้วผนวก MAC สดจากช่วงเปิดเรื่องไม่รอด: MAC ใหม่สุดผ่านด้วยตัวเอง ValidatePDFMAC พอใจ และห่วงโซ่ก็ยังตกที่รีวิชัน 1 ด้วย pmcfRevisionInvalid
ลำดับการเขียน signature: คีย์ trailer มาก่อน signatureDigest มาหลัง
เมื่อ MAC ผูกติดกับ CMS signature แทนที่จะยืนอยู่โดด ๆ ลำดับการเขียนก็เลิกเป็นคำถามเชิงสไตล์ HotPDF กำหนดให้ /AuthCode, /KDFSalt, developer extension ของ ISO 32004 และ /SigObjRef ถูกเขียนเข้ารีวิชันเดียวกันก่อนที่ /ByteRange ของ signature จะถูกคำนวณ ผนวกสิ่งใดเพิ่มภายหลัง ไบต์เหล่านั้นจะไปตกอยู่นอกช่วงที่ signature คลุม ได้ไฟล์ที่ signature ตรวจผ่านแต่การผูก MAC ไม่ถูกลงนาม digest ทั้งสองวิ่งสวนทางกัน ซึ่งดูเป็นวงกลมตอนแรกเห็นแต่ไม่ใช่ PDF MAC signatureDigest ผูกกับ content octets ดิบของ CMS SignerInfo.signature OCTET STRING — ไม่ใช่ทั้ง CMS DER และไม่ใช่ signed attributes — มันจึงถูกประกอบหลังจากค่า signature ดิบมีตัวตนแล้ว และถูกฉีดเข้าเป็น unsigned attribute ชื่อ id-attr-pdfMacData เนื่องจาก /Contents ถูกกีดออกจาก ByteRange ของ signature และ unsigned attribute ไม่เคยถูกป้อนเข้าการคำนวณ signature ลำดับ produce-signature, build-MAC, wrap-CMS จึงปิดสนิทโดยไม่มีวงจรเข้ารหัสไหนค้าง ผลพวงสองข้อ: sentinel ของ /ByteRange กับ placeholder ของ /Contents ต้องคงเป็น plaintext และอยู่นอก object stream แม้ในไฟล์ที่เข้ารหัส ไม่เช่นนั้นตัว patch ความกว้างคงที่หาพวกมันไม่เจอ และเมื่อ digest ของ MAC ก็เป็น SHA-256 ด้วย ค่า signing digest จะถูกนำกลับมาใช้ต่อทันที มิฉะนั้นบริบท digest ทั้งสองถูกอัปเดตรวดเดียวในหนึ่งรอบการวิ่งผ่าน output stream
var
Pdf: THotPDF;
Options: THPDFPDFMACOptions;
Info: THPDFPDFMACValidationInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'unsigned.pdf';
Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aesgcm;
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.ProtectOptions := [prPrint, prExtractContent];
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.AddSignedSignatureField('Approval',
Rect(72, 120, 280, 160), 16384);
Pdf.EndDoc;
Options := THPDFPDFMACOptions.Modern; // digest เอกสาร SHA-384
if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
begin
// Location = pmlAttachedToSignature และสอง digest
// ถูกรายงานแยกจากกัน
Writeln('signature object : ', Info.SignatureObjectNumber);
Writeln('signature digest : ', Info.SignatureDigestMatched);
Writeln('full file coverage: ', Info.FullFileCoverage);
Writeln('perms authentic : ', Info.PermissionsAuthenticated);
end;
finally
Pdf.Free;
end;
end;
การตรวจสอบเดินย้อนเส้นทางเดิมจากอีกฝั่ง: อ่าน /AuthCode แบบ direct จาก classic cross-reference trailer ที่ active อยู่ ตาม /SigObjRef แบบ indirect ที่รู้เรื่อง generation ยืนยันว่ามันผูกกับ /V ของฟิลด์ signature หนึ่งเดียวนั้น และรายงานความล้มเหลวของ document digest แยกจาก signature digest สองการวินิจฉัยนี้เป็นคนละเรื่อง การยุบให้เหลือ boolean เดียวทิ้งข้อมูลชิ้นเดียวที่บอกว่าหน้ากระดาษหรือค่า signature ถูกแตะถูกแก้ ถ้าคุณทำงาน CMS กันอยู่แล้ว สิ่งนี้วางอยู่ข้าง บทความการลงนาม PAdES และ คู่มือการตรวจ signature ของเอกสารที่โหลดแล้ว
อย่าไว้ใจ /P: ถอดรหัสสตริง /Perms ขนาดสิบหกไบต์ก่อน
ISO/TS 32004 ประกาศว่า "เอกสารฉบับนี้ต้องการ PDF MAC" ผ่าน permission bit 13 และวิธีอ่านที่เห็นได้ชัดคือวิธีที่ผิด เพราะจำนวนเต็ม /P ใน encryption dictionary เป็น plaintext และไม่ถูกยืนยันตัวตน — ใครก็พลิกบิตนั้นใน text editor แล้วดาวน์เกรดข้อกำหนดได้ ISO 32000-2 §7.6 วางคำตอบไว้ในรายการ /Perms และ HotPDF ใช้มัน: ถอดรหัสสตริง /Perms ขนาดสิบหกไบต์ด้วยคีย์เข้ารหัสของไฟล์ภายใต้ AES-256 CBC, IV ศูนย์, ไม่มี padding แล้วตรวจทุกฟิลด์ของ plaintext ก่อนเชื่อสิ่งใด ไบต์ที่ 1 ถึง 4 เก็บค่าสิทธิ์แบบ little-endian และต้องเท่ากับจำนวนเต็ม /P เป๊ะ ไบต์ที่ 5 ถึง 8 เป็น 0xFF ไบต์ที่ 9 เป็นฟล็อกเข้ารหัส metadata ค่า T หรือ F ไบต์ที่ 10 ถึง 12 เป็น marker ตรงตัว adb ก็ต่อเมื่อทุกอย่างครบว่า PermissionsAuthenticated กลายเป็น True และ bit 13 จึงถูกอ่าน — และจงจำขั้วของมัน ข้อกำหนด MAC ถูกประกาศเมื่อบิต 0x1000ไม่ถูกเซ็ต ความไม่ตรงกันระหว่าง /P กับสิทธิ์ที่ถอดรหัสแล้วไม่ใช่คำเตือนที่ log แล้วเดินผ่านไปได้ มันคือชุดสิทธิ์ปลอม และการตอบสนองที่ถูกต้องคือ fail closed
ความคล่องตัวด้านอัลกอริทึมหยุดที่ digest
ISO/TS 32004 ให้เลือก document digest และเลือกได้เฉพาะ document digest เท่านั้น HotPDF คง HMAC-SHA-256 ไว้สำหรับการยืนยันตัวตน, HKDF-SHA-256 ตาม RFC 5869 สำหรับ key derivation และ AES-256 key wrap ตาม RFC 3394 ตรึงอยู่ใต้ THPDFPDFMACDigestAlgorithm ที่แปรผันได้ครอบตั้งแต่ pmdaSHA256 ถึง pmdaSHA3_512 เพราะความผิดพลาดแบบธรรมชาติคือการเข้าใจว่า "โปรไฟล์ SHA3-512" เป็นใบอนุญาตให้สลับ HMAC ตามไปด้วย ซึ่งให้ไฟล์ที่ไม่ใช่ PDF MAC ในความหมายที่ interoperable ได้อีกแล้ว รายละเอียดติดตั้งหนึ่งจุดที่ควรลอกถ้าคุณเขียน verifier ของตัวเอง: อ่าน digest OID จาก CMS AuthenticatedDataก่อนแฮชช่วงไบต์ เพราะการ hardcode SHA-256 แล้วค่อยไล่ปรับทีหลังเปลี่ยนความคล่องตัวให้เหลือเป็นแค่ฉลาก และยอมให้ไฟล์ที่เป็นภัยหลอกให้คุณ stream ทั้งเอกสารก่อนจะค้นพบว่าอัลกอริทึมนั้นไม่เคยถูกรองรับเสียด้วยซ้ำ CMSAlgorithmProtection, อัลกอริทึม digest ของ AuthenticatedData, messageDigest ของ integrity-info และ digest ของ byte range ต้องระบุอัลกอริทึมตัวเดียวกันทั้งหมด และความไม่ตรงกันใด ๆ จบด้วย fail closed
var
Options: THPDFPDFMACOptions;
begin
Options := THPDFPDFMACOptions.Compatibility; // SHA-256, รับทั้งหกแบบ
Options := THPDFPDFMACOptions.Modern; // SHA-384, ปฏิเสธ 256 บิต
Options := THPDFPDFMACOptions.HighAssurance; // SHA3-512 เท่านั้น, AES-GCM
// โปรไฟล์กำหนดเองถูกต้องตามกติกา แต่อัลกอริทึมที่มันใช้สร้าง
// ต้องปรากฏใน allowlist ของการ validate ด้วย ไม่เช่นนั้นการตั้งค่า
// จะถูกปฏิเสธก่อนไบต์แรกจะถูกเขียน
Options.Profile := pmppCustom;
Options.DigestAlgorithm := pmdaSHA512;
Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
Options.RequireAESGCM := True;
end;
PDF MAC พิสูจน์อะไรได้และพิสูจน์อะไรไม่ได้
ห่วงโซ่ PDF MAC ที่ตรวจผ่านพิสูจน์ว่ารีวิชันที่ถูกคุ้มครองทุกรอบเหมือนกันทีละไบต์กับสิ่งที่ถูกเขียนโดยผู้ถือคีย์เข้ารหัสของไฟล์ ว่าไม่มีรีวิชันที่ถูกคุ้มครองถูกลบหรือถูกสลับลำดับ และไม่มีรีวิชันที่ไม่ถูกคุ้มครองถูกผนวกต่อท้ายจุดยึด — พอดีเป๊ะกับกลุ่มการโจมตีที่ การเข้ารหัส AES-256 แบบเปล่าเปิดช่องไว้ เพราะความลับบอกอะไรเกี่ยวกับความคงเดิมไม่ได้เลย และ PDF ที่เข้ารหัสแล้วถูกฝังรีวิชันปลอมเข้าไปก็ถอดรหัสยินดีเหมือนตัวสมบูรณ์ สิ่งที่มันพิสูจน์ไม่ได้คือความเป็นเจ้าของงาน คีย์ MAC ถูก derive จากคีย์เข้ารหัสของไฟล์ ใครก็ที่เปิดเอกสารได้ก็ผลิต MAC ที่ถูกต้องบนเวอร์ชันแก้ไขได้ ผู้รับที่ถูกต้องทุกคนรวมอยู่ด้วย มันเป็น primitive แบบสมมาตร และ primitive แบบสมมาตรพิสูจน์การระบุตัวบุคคลไม่ได้ ถ้าคุณต้องรู้ว่าใครแก้อะไร คุณต้องการ digital signature ที่มีใบรับรองยืนอยู่ข้างหลัง แล้ว PDF MAC จะเสริมมันด้วยการคุ้มครองโครงสร้าง incremental ที่ signature เพียงลำพังคลุมไม่ถึง ปฏิบัติกับทั้งคู่เป็นชั้น และปล่อยให้คำตัดสินสองตัวถูกรายงานแยกกันแทนการยุบเป็นไอคอนสถานะตัวเดียว
entry point ของ PDF MAC ที่อธิบายไว้ในบทความนี้ — AddStandalonePDFMAC, SignPDFWithPFXAndAttachedPDFMAC, ValidatePDFMAC และ ValidatePDFMACChain — มาพร้อม HotPDF Delphi Component ฉบับมาตรฐานสำหรับ Delphi และ C++Builder โดยหน้าผลิตภัณฑ์เก็บเอกสารอ้างอิงฉบับเต็มของเรกคอร์ด options, การนับ enum สถานะ และอาร์เรย์ตรวจสอบรายรีวิชันเอาไว้