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

คุณแพตช์ PDF ที่เข้ารหัสไว้อย่างเงียบๆ ไม่ได้ใน Delphi

ลอง PDF ใบแจ้งหนี้ที่เข้ารหัสด้วย AES-256 ไว้แล้ว แล้วขอให้ PDFium Component สำหรับ Delphi และ C++Builder (PDFiumPas) ประทับมันเป็น PDF/A สำหรับการเก็บถาวร หรือเซ็นมันด้วย PAdES ผ่านการอัปเดตแบบ incremental แทนที่จะเขียนใหม่ทั้งหมด ไลบรารีจะไม่ไปถึงจุดนั้นด้วยการแพตช์ไบต์ที่เข้ารหัสไว้โดยตรงเลย ตัวฉีดเครื่องหมายความสอดคล้องทั้งหกของมันตรวจจับ entry /Encrypt ที่มีอยู่แล้ว และส่งต้นทางผ่านไปโดยไม่เปลี่ยนแปลงทุกไบต์ และตัวเซ็น PAdES ของมันยก exception แทนที่จะสร้างลายเซ็นที่ validator ใดๆ จะไม่ยอมรับ

นั่นเป็นคำถามที่ต่างจากการตรวจสอบ PDF ที่คุณไม่ได้สร้างเองเพื่อหาความเสี่ยงที่ซ่อนอยู่ ซึ่งเป็นแบบฝึกหัดแบบอ่านอย่างเดียวของตัวเอง บทความนี้พูดถึงฝั่งการเขียนของขอบเขตความไว้วางใจเดียวกัน คือโค้ดของคุณเองได้รับอนุญาตให้ทำอะไรกับไฟล์ที่ไบต์ของมันถูกล็อคไว้หลังรหัสผ่านของคนอื่นอยู่แล้ว ทันทีที่โค้ดนั้นพยายามเพิ่มอะไรเข้าไปในมันภายหลัง

ISO 32000-1 ต้องการอะไรเมื่อคุณอัปเดต PDF ที่เข้ารหัสไว้

ISO 32000-1 §7.5.6 ต้องการให้ trailer ของการอัปเดตแบบ incremental ทำซ้ำทุก entry จาก trailer ก่อนหน้ายกเว้น /Prev และ Table 15 ระบุ /Encrypt ไว้ในบรรดา entry ที่ trailer พกได้ ทิ้งมันออกจาก trailer ใหม่แล้ว reader ที่เป็นไปตามมาตรฐานจะไม่มีเหตุผลที่จะสงสัยการละเว้นนั้นเลย trailer ล่าสุดคือฉบับที่เชื่อถือได้ ดังนั้น reader ที่ไม่พบ /Encrypt ที่นั่นจะตัดสินว่าทั้งไฟล์ไม่ได้เข้ารหัสและพยายาม parse เนื้อหาเก่าที่ยังคงเข้ารหัสอยู่เป็นไบต์ธรรมดา คง /Encrypt ไว้ใน trailer ใหม่แต่เขียน object ของการอัปเดตเองเป็น plaintext แล้วความล้มเหลวก็แค่ขยับไปอีกขั้นตอนหนึ่งเท่านั้น reader ตรวจจับการเข้ารหัสได้ถูกต้อง รัน object ทุกตัวที่มันแตะผ่าน cipher ของไฟล์ รวมถึงตัวใหม่ที่ไม่เคยถูกเข้ารหัสตั้งแต่แรกด้วย และได้ noise กลับมาสำหรับเนื้อหาที่อ่านออกได้อย่างสมบูรณ์แบบก่อนที่การถอดรหัสจะแตะมัน ข้อผิดพลาดทั้งสองแบบสร้างไฟล์ที่ดูเหมือนการอัปเดตแบบ incremental ปกติที่มีรูปแบบถูกต้องในระดับไบต์ จนกว่า reader ที่เป็นไปตามมาตรฐานจะเปิดมัน

ตัวฉีดเครื่องหมายหกตัว ประตูการเข้ารหัส v2.14.2 เดียว

PDFiumPas มาพร้อมตัวฉีดเครื่องหมายระดับไบต์หกตัว หนึ่งตัวสำหรับแต่ละเซตย่อย ISO PDF ที่มันติดป้ายได้ คือ PDF/A (ISO 19005), PDF/X (ISO 15930), PDF/UA (ISO 14289-1), PDF/E-1 (ISO 24517-1), PDF/R-1 (ISO 23504-1) และ PDF/VT-1 (ISO 16612-2) แต่ละตัวรับไบต์ที่ FPDF_SaveAsCopy ของ PDFium เองเขียนไว้แล้ว และซ้อนการอัปเดตแบบ incremental ที่สองซึ่งเล็กกว่าทับพวกมัน คือสตรีม XMP metadata ใหม่, การแก้ไข catalog dictionary ที่ชี้ไปที่มัน และสำหรับเซตย่อยที่เน้นการพิมพ์ก็มี OutputIntent กับ ICC profile ตั้งแต่ v2.14.2 เป็นต้นมา ทุกตัวใน InjectPdfAMarkers, InjectPdfXMarkers, InjectPdfUaMarkers, InjectPdfEMarkers, InjectPdfRMarkers และ InjectPdfVTMarkers อ่าน trailer ต้นทางก่อน และถ้ามันรายงาน entry /Encrypt ที่มีอยู่แล้ว จะ copy ต้นทางไปยังสตรีมปลายทางโดยไม่เปลี่ยนแปลงและคืนค่าทันที ไม่มี XMP, ไม่มี OutputIntent, ไม่มีการแก้ไข catalog ผู้เรียกจะได้ไฟล์ต้นฉบับกลับมาทุกไบต์

var
  Src, Dst: TFileStream;
  Opts: TPdfXSaveOptions;
begin
  Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
  Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
  try
    Opts.Conformance := pxc4;
    InjectPdfXMarkers(Src, Dst, Opts);
    // pdfx-attempt.pdf is byte-identical to the source: still encrypted,
    // no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
    // nothing was corrupted either
  finally
    Dst.Free;
    Src.Free;
  end;
end;

ได้รับอนุญาตให้เข้ารหัสได้ ไม่เหมือนกับปลอดภัยที่จะฉีดเข้าไป

PDF/E-1 และ PDF/R-1 ทั้งคู่อนุญาตให้เอกสาร host ของมันเข้ารหัสได้อย่างชัดเจนในระดับสเปค ซึ่งอ่านดูเหมือนข้อยกเว้นจนกว่าคุณจะดูว่าอะไรต้องเกิดขึ้นจริงบนดิสก์ ISO 24517-1 §6.3 อนุญาตการเข้ารหัสสำหรับ PDF/E-1 และ ISO 23504-1 §6.2.3 อนุญาตมันสำหรับ PDF/R-1 ถ้า header ประกาศ %PDF-2.0 ไม่มีข้อไหนพูดอะไรเลยว่าตัวประมวลผลภายหลังระดับไบต์สามารถเพิ่ม object แบบ plaintext เข้าไปใน container ที่เข้ารหัสไว้นั้นได้อย่างปลอดภัยหรือไม่ และมันทำไม่ได้ ด้วยเหตุผล §7.5.6 เดียวกับที่ใช้กับเซตย่อยอื่นทุกตัว ตัวตรวจสอบความสอดคล้องของ PDFiumPas เองสำหรับสองโปรไฟล์นี้ คือ ValidatePdfECompliance และ ValidatePdfRCompliance บันทึกการมีอยู่ของ /Encrypt อย่างจงใจโดยไม่ flag มันว่าเป็นข้อบกพร่อง ซึ่งถูกต้องสำหรับ validator แบบอ่านอย่างเดียวที่ไม่เคยเขียนไบต์เลย มันก็เป็นรูปแบบที่มองข้ามได้ง่ายและสมมติว่าตัวฉีดพี่น้องของมันไม่ต้องการ guard แยกต่างหาก ในเมื่อตัวฉีดเป็นฟังก์ชันเดียวในคู่นั้นที่ต้องปฏิเสธจริงๆ

SaveAsPdfX ถอดรหัสเอกสารของคุณอย่างเงียบๆ หรือไม่

ใช่ ทุกครั้งที่คุณผ่าน public convenience method แทนการเรียกตัวฉีดโดยตรง ทุกตัวใน TPdf.SaveAsPdfA, SaveAsPdfX, SaveAsPdfUa, SaveAsPdfE, SaveAsPdfR และ SaveAsPdfVT render เอกสารปัจจุบันไปยังสตรีมชั่วคราวด้วย SaveAs(Tmp, saRemoveSecurity) ก่อนที่จะส่งไบต์เหล่านั้นให้ตัวฉีดที่ตรงกัน saRemoveSecurity แม็ปไปยัง flag FPDF_REMOVE_SECURITY ของ PDFium เอง ดังนั้นสำเนาชั่วคราวที่ตัวฉีดได้รับจึงไม่เคยถูกเข้ารหัสมาตั้งแต่แรก และ guard /Encrypt ของตัวฉีดก็ไม่มีเหตุผลที่จะกระตุ้นเลย เอาต์พุตพกเครื่องหมาย PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 หรือ PDF/VT-1 ของคุณ แต่มันไม่ได้รับการป้องกันด้วยรหัสผ่านใดก็ตามที่เปิดต้นทางอีกต่อไป

การแลกเปลี่ยนนั้นมองไม่เห็นจนกว่าจะมีใครสักคนที่ปลายทางเปิดสำเนา archival ที่ "ป้องกันไว้" นั้นโดยไม่มีรหัสผ่านและสังเกตว่ามันเปิดได้เฉยๆ ทางแก้ไม่ใช่การเรียก method อื่น PDFiumPas ไม่มี saAddSecurity ที่จับคู่กับ saRemoveSecurity เพราะเอนจิ้น PDFium ที่อยู่ข้างใต้ไม่เคยถูกสร้างมาเพื่อเขียนการเข้ารหัสใหม่เลย มีแค่เพื่อลบมันเท่านั้น ถ้าทั้งสองคุณสมบัติสำคัญสำหรับไฟล์หนึ่ง การเข้ารหัสต้องเป็นขั้นตอนแยกต่างหากที่คุณเป็นเจ้าของ ใช้หลังเครื่องหมายความสอดคล้อง ไม่ใช่พับเข้าไปในการเรียก SaveAsPdfA เดียวกัน

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';           // needed to open the source at all
    Pdf.FileName := 'signed-encrypted-invoice.pdf';
    Pdf.LoadDocument;
    Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
    // invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
    // ran first inside SaveAsPdfA: the output opens without a password
  finally
    Pdf.Free;
  end;
end;

เกิดอะไรขึ้นเมื่อคุณเซ็น PDF ที่เข้ารหัสไว้ด้วย PAdES

PDFiumPas ปฏิเสธโดยตรง แทนที่จะทิ้งคำขอไปเงียบๆ แบบที่ตัวฉีดเครื่องหมายทำ TPdf.SignPades และ SignPadesToStream ทั้งคู่วิ่งผ่าน SignPadesBytes ภายใน และสิ่งแรกที่มันทำหลัง parse trailer ต้นทางคือตรวจสอบหา /Encrypt ถ้า entry นั้นมีอยู่ มันจะยก EPadesCrypto พร้อมข้อความ "SignPadesBytes: the source document is encrypted; remove encryption before signing" แทนที่จะดำเนินการต่อไปอีก InjectPadesDssMarkers ฟังก์ชันที่ฝังใบรับรอง, การตอบกลับ OCSP และ CRL สำหรับการตรวจสอบความถูกต้องระยะยาว ใช้การตรวจสอบเดียวกันด้วยเหตุผลเดียวกัน พร้อมข้อความของตัวเอง "InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material"

เหตุผลตรงนี้เข้มงวดกว่าการส่งผ่านของตัวฉีดเครื่องหมาย และเป็นความจงใจ การส่งผ่านแบบเงียบๆ ปลอดภัยสำหรับตราประทับ PDF/A เพราะการข้ามมันทิ้งคุณไว้กับ PDF ที่ถูกต้องเดียวกับที่คุณเริ่มต้นไว้ แค่ไม่มีป้ายเท่านั้น การเซ็นล้มเหลวแบบเงียบๆ แบบนั้นไม่ได้ ลายเซ็นที่ไม่เคยถูกเพิ่มเข้าไปอย่างเงียบๆ ดูเหมือนกันเป๊ะกับลายเซ็นที่ถูกเพิ่มสำเร็จ สำหรับโค้ดที่เรียกใช้ใดๆ ที่ตรวจสอบแค่ผลลัพธ์ boolean เท่านั้น EPadesCrypto สืบทอดจากคลาส Exception ธรรมดา ดังนั้นการดักจับมันจึงเป็น exception handling ปกติ ไม่ใช่ข้อตกลง control-flow พิเศษที่คุณต้องเรียนรู้

var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.Password := 'open-secret';
    Pdf.FileName := 'encrypted-contract.pdf';
    Pdf.LoadDocument;
    try
      Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
    except
      on E: EPadesCrypto do
        // E.Message: 'SignPadesBytes: the source document is encrypted;
        // remove encryption before signing'
        raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
    end;
  finally
    Pdf.Free;
  end;
end;

การเรียงลำดับตราประทับความสอดคล้อง ลายเซ็น และการเข้ารหัส

ทางแก้ในทางปฏิบัติคือการเรียงลำดับ ไม่ใช่ไลบรารีอื่น ใช้เครื่องหมาย PDF/A, PDF/X, PDF/UA, PDF/E-1, PDF/R-1 หรือ PDF/VT-1 ก่อน เพิ่มลายเซ็น PAdES ใดๆ ต่อไป และรันขั้นตอนใดก็ตามใน pipeline ของคุณที่เป็นเจ้าของการเข้ารหัสจริงๆ หลังจากนั้นเท่านั้น ไม่ว่าจะเป็นตัวเขียน PDF เฉพาะทาง, อุปกรณ์การเซ็น หรือ implementation AES ของคุณเอง ชั้น incremental-update ของ PDFiumPas เข้ากับกลางลำดับนั้นอย่างเป็นธรรมชาติ ต่อท้าย object เล็กๆ ที่เจาะจงเข้ากับไฟล์ที่เสร็จแล้วในทางอื่น และการเข้ารหัสอยู่ที่ท้ายสุดพอดี เพราะมันเป็นการดำเนินการเดียวใน chain ที่ PDFiumPas เองทำหรือย้อนกลับไม่ได้

ไม่มีอะไรในนี้ที่เปลี่ยนวิธีที่ PDFiumPas อ่านข้อมูล trailer และ cross-reference ที่ทุกการอัปเดตแบบ incremental พึ่งพาเลย ซึ่งเป็นแหล่งความละเอียดอ่อนของมันเองเมื่อ xref stream เข้ามาเกี่ยวข้อง การตรวจสอบ object และ xref stream ของ PDF ครอบคลุมว่าเส้นทางการอ่าน trailer เดียวกันนั้นจัดการโครงสร้างที่บีบอัดของ PDF 1.5+ อย่างไร และเมื่อเอกสารพร้อมสำหรับอะไรที่แข็งแรงกว่าตราประทับความสอดคล้องแล้ว การเซ็น PDF ด้วยลายเซ็น PAdES B-B ใน Delphi คือจุดที่ SignPades รับช่วงต่อจากจุดที่บทความนี้ทิ้งไว้พอดี

ตัวฉีดเครื่องหมายและ method SignPades ที่อธิบายในบทความนี้มาพร้อมกับPDFium Componentสำหรับ Delphi และ C++Builder ควบคู่ไปกับการ render และการตรวจสอบแบบอ่านอย่างเดียวที่ PDFium มีให้โดยกำเนิด