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

PDF Crypt Filter ใน Delphi: นโยบาย StmF, StrF และ EFF

HotPDF Delphi PDF component implement crypt filter model ของ ISO 32000-1 §7.6.5 เป็น policy อิสระสามชุดแทน switch เดียว ConfigureCryptFilterDefaults กำหนด string filter /StrF, stream filter /StmF และ embedded-file filter /EFF แยกกัน SetStreamCryptFilter override stream เดียว และ GetLoadedCryptFilterInfo รายงานสิ่งที่ไฟล์ขาเข้าประกาศไว้ bug ด้าน encrypted-PDF interop ส่วนใหญ่ซ่อนอยู่ในช่องว่างระหว่างสามอย่างนี้

นี่คือ failure ที่ทำให้คนต้องลงมาดู layer นี้ ทีมหนึ่งส่ง document ที่ page content ต้องอ่านได้โดย downstream tool แต่ payload ที่แนบมาต้องอ่านไม่ได้ จึงตั้ง /EFF /StdCF และปล่อย /StmF /Identity Acrobat เปิดได้ปกติ แต่ third-party reader ที่ทำตาม spec ส่ง attachment กลับมาเป็น ciphertext garbage เพราะ /EFF เป็น policy ฝั่ง producer ว่า filter ใดใช้กับ embedded file ขณะที่ reader ทั่วไปยัง resolve stream ที่ไม่มี marker ผ่าน /StmF วิธีแก้ไม่ใช่เปลี่ยนค่า /EFF แต่คือใส่ explicit /Crypt filter ลงบน embedded-file stream เอง

crypt filter layer ควบคุมอะไรจริง

crypt filter อยู่ระหว่าง encryption algorithm กับ object graph และตัดสินว่า algorithm จะแตะ object ใด ไม่ใช่ทำงานอย่างไร dictionary /CF ใน encryption dictionary map name ไปยัง filter definition ซึ่งแต่ละตัวมี method /CFM, /Length ที่อาจมีหรือไม่มีก็ได้ และ /AuthEvent entry ระดับบนสามตัวคือ /StrF, /StmF และ /EFF จะเลือก filter ที่มีชื่อเหล่านั้นให้ใช้กับ string, stream ที่ไม่มี explicit filter และ embedded file ตามลำดับ HotPDF จำกัดสิ่งที่ built-in handler จะ เขียน อย่างตั้งใจ ConfigureCryptFilterDefaults รับเฉพาะ reserved name ของ handler ที่กำลังใช้ Standard security handler emit ได้ /StdCF หรือ /Identity ส่วน public-key handler emit ได้ /DefaultCryptFilter หรือ /Identity ถ้าเป็นอย่างอื่นจะ raise EArgumentException ที่จุด call filter ที่ external producer เขียนด้วยชื่ออื่นยังถูก preserve ใน load, inspection และ compatibility-rewrite path ดังนั้น HotPDF จึง conservative เมื่อเป็น writer แต่ permissive เมื่อเป็น reader ยังมี guard อีกสองข้อ call จะ raise EInvalidOpException หลัง document serialization เริ่มแล้ว และจะ raise อีกครั้งหาก document อยู่ใน incremental update เพราะ encryption policy เปลี่ยนระหว่าง revision ของไฟล์เดียวกันไม่ได้

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // เข้ารหัส string, ปล่อย page stream เป็น plaintext, เข้ารหัส attachment
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

มีข้อจำกัดหนึ่งที่ควรพูดตั้งแต่แรก เพราะมันตรวจช้าและทำให้หลายคนประหลาดใจ named crypt filter ใน HotPDF ต้องใช้ document encryption แบบ aes128, aes256 หรือ aesgcm หาก configure filter policy ทับบน RC4 k40 หรือ k128 validation pass ที่ทำงานเมื่อเปิด encryption จะ raise แทนการเลื่อน key type เป็นค่าอื่นให้เงียบ ๆ นี่เป็นท่าทีเดียวกับ เส้นทาง AES-256 PDF encryption ใน Delphi ส่วนอื่น คือปฏิเสธ configuration ที่กำกวม แทนการเดาว่า caller หมายถึงอะไร

ทำไม entry /Length จึงหมายถึงสองอย่าง

เพราะ spec นิยามมันด้วยหน่วยคนละแบบตาม security handler และ HotPDF ต้องเคารพทั้งสองแบบ ใน crypt filter dictionary ที่ /CFM เป็น /V2 entry /Length มีหน่วยเป็น byte ภายใต้ Standard security handler แต่เป็น bit ภายใต้ public-key handler ส่วน /Length ใน encryption dictionary ที่อยู่ข้าง /V (ISO 32000-1 §7.6.2) มีหน่วยเป็น bit เสมอ อ่าน filter dictionary ที่มี /Length 16 แล้วจะได้ 128-bit key ในไฟล์ที่ใช้ Standard handler แต่เป็นไฟล์ที่ถูกปฏิเสธในไฟล์ public-key HotPDF normalize เรื่องนี้ตอน capture loaded configuration โดยคูณ /V2 filter /Length ด้วยแปดเฉพาะเมื่อไฟล์ไม่ได้เข้ารหัสแบบ public-key fallback ไปใช้ document-level /Length เมื่อ filter ไม่มีค่าของตัวเอง และเก็บผลไว้ใน THPDFCryptFilterInfo.KeyLengthBits ส่วน AESV2 ถูกตรึงที่ 128 bit และ AESV3 กับ AESV4 ที่ 256 เพราะ method เหล่านี้ไม่เปิดให้ต่อรอง key size จุดที่ strict คือถัดไป: /V2 ยอมรับเฉพาะ 40-bit และ 128-bit filter ที่ resolve ได้เป็นค่าอื่นจะถูก mark ว่า unavailable และ operation จะ fail แทนการปัดเป็น 128 โดยคิดว่า producer ส่วนใหญ่คงหมายถึง 128 การ normalize key length แบบเงียบ ๆ คือวิธีสร้างไฟล์ที่ decrypt ได้ในเครื่องคุณแต่ไม่มีที่อื่นเปิดได้

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF และ /StmF default เป็น Identity ส่วน /EFF default เป็น /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

/CFM /None รับประกันอะไร และ /Identity ต่างกันอย่างไร

ผลลัพธ์สุดท้ายเหมือนกันแต่ได้มาคนละทาง และการปนสองอย่างนี้เข้าด้วยกันจะทำให้ lookup พัง named filter ที่มี /CFM เป็น /None กับ named filter ที่ไม่มี /CFM เลยต่างหมายถึง filter นี้ไม่ทำ encryption หรือ decryption ทั้งคู่ HotPDF map entry ที่หายไปเป็น None ก่อน resolve ดังนั้นทั้งสองจึงลงท้ายที่ hcfmNone พร้อม recorded key length เป็นศูนย์ ส่วน /Identity แตกต่างในเชิงชนิด มันเป็น reserved name ที่ bypass /CF lookup ไปเลย ดังนั้น document สามารถอ้าง /Identity โดยไม่ต้อง define ไว้ใน /CF ที่ใด PDF name แยกตัวพิมพ์เล็กใหญ่ ซึ่งทำให้รายละเอียด implementation อีกข้อเป็นสิ่งที่ต่อรองไม่ได้ crypt filter lookup ห้ามเป็น case-insensitive เด็ดขาด HotPDF resolve ชื่อ sub-dictionary ของ /CF, entry /Length ของ filter และ /Type check ของ stream ด้วย case-sensitive dictionary lookup ไฟล์ที่ define /stdcf แต่ /StmF ชี้ไปยัง /StdCF เป็น malformed file และการถือว่าสอง key นี้เหมือนกันจะเปลี่ยน authoring bug ที่ตรวจจับได้ให้กลายเป็น wrong key ที่ถูกใช้เงียบ ๆ กับทุก stream ใน document

ทำให้ /EFF มีผลกับ embedded-file stream

เมื่อ /EFF ต่างจาก /StmF embedded-file stream ต้องมี /Crypt entry นำหน้าอย่างชัดเจนใน /Filter และมี /DecodeParms dictionary ที่ตรงกัน โดยมี /Name อยู่ตำแหน่งเดียวกันใน array HotPDF คำนวณเรื่องนี้ต่อ stream ตอน save โดยตรวจ /Type /EmbeddedFile สืบทอด embedded-file filter ที่ configure ไว้ และ emit explicit /Crypt marker เฉพาะเมื่อชื่อที่สืบทอดมาต่างจาก effective stream default หาก /EFF กับ /StmF เหมือนกันจะไม่เขียน marker เพราะ reader จะ resolve ไปยัง filter เดียวกันอยู่แล้ว ตำแหน่งใน array จึงสำคัญพอ ๆ กับชื่อ เมื่อ HotPDF อ่าน stream กลับ มันจะ scan /Filter หา entry /Crypt จำ index ไว้ แล้ว lookup index เดียวกัน ใน array /DecodeParms เพื่อหา /Name ถ้า /Crypt อยู่ index 0 แต่ parameter อยู่ index 1 ผลที่ resolve ได้จะเป็น /Identity ไม่ใช่ filter ของคุณ นี่เป็นเหตุผลที่ writer เติม null ใน parameter array เมื่อ stream เดิมมี /Filter แต่ไม่มี /DecodeParms ตำแหน่งต้อง align กัน

ยังมี trap ที่คมกว่านั้น หาก /Filter หรือ /DecodeParms เดิมเป็น indirect object ซึ่งพบได้บ่อยในไฟล์จาก generator ที่ share filter array เดียวกับหลาย stream การ insert /Crypt ตรงจุดนั้นจะ mutate shared filter graph และทำให้ stream อื่นทุกตัวที่ชี้มาหามันเสีย HotPDF จึง resolve indirect object แล้ว clone เป็น direct object ที่เป็นของ stream โดยเฉพาะก่อน โดยล้าง object และ generation number เพื่อไม่ให้ indirect root เดิมฝังอยู่ใน array ใหม่ สำหรับ stream ที่ใช้ ASCIIHexDecode อยู่แล้ว ผลลัพธ์ที่ serialize จะเป็น /Filter [ /Crypt /ASCIIHexDecode ] พร้อม /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ] หลักการรักษาตำแหน่งแบบเดียวกันควบคุม filter chain อื่นทั้งหมด รวมถึง chain ที่อธิบายใน การ extract image จาก loaded PDF ผ่าน decode filter

// Editor ถือ loaded document อยู่แล้ว และ /Filter ของ ContentStream เป็น
// indirect /ASCIIHexDecode name
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// ชื่อว่างจะล้าง override และลบ /Crypt เก่า
// พร้อม decode parameter ใน save ครั้งถัดไป
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

object stream สืบทอด /Encrypt policy ของ document หรือไม่

ไม่ และการคิดว่าใช่เป็นวิธีที่แน่นอนในการสร้าง garbage object stream ต้องทำตาม policy /StmF จริง หรือ marker /Crypt ของตัวเอง การมี /Encrypt dictionary อยู่ไม่ได้ทำให้ทุก /ObjStm container กลายเป็น ciphertext document ที่มี /StmF /Identity จะมี object stream เป็น plaintext แม้ string ของมันจะถูกเข้ารหัสทั้งหมด และ decoder ที่ decrypt มันอีกครั้งจะส่ง input ที่ไม่เคยเป็น deflate output เข้า inflate stage

ผลที่เกิดกับ member object คือส่วนที่ควรอ่านซ้ำ ตาม ISO 32000-1 §7.5.7 string ภายใน encrypted object stream จะเป็น plaintext อยู่แล้วเมื่อ decrypt container เอง ดังนั้นการ decrypt ซ้ำจะกลายเป็น double-decrypt HotPDF ป้องกันด้วยการ query ว่า container ของ type-2 object ถูกเข้ารหัสหรือไม่ แล้ว skip object เมื่อเป็นเช่นนั้น โดยนับจำนวน skip ไว้ใน XRefProbeDecryptObjStmSkips เพื่อเป็นหลักฐานตรง ๆ ว่า guard ทำงาน เมื่อ container เป็น plaintext member string ไม่เคยถูกปกป้องด้วยอะไรเลย HotPDF จึง materialize member เหล่านั้นแล้วใช้ /StrF กับแต่ละตัวแยกกัน โดย key ตาม implementation จริงใช้ member object number และ generation ไม่ใช่ object number ของ /ObjStm ที่ครอบอยู่ หากกลับกฎนี้ในไฟล์ที่มี policy ผสม string ใน compressed object ทุกตัวจะ decode เป็น noise กฎระดับ container ในเรื่องนี้มีอธิบายเพิ่มเติมใน PDF object stream และ incremental update

จุดที่ HotPDF ปฏิเสธการเดา

crypt filter semantics ไม่มีอยู่ต่ำกว่า /V 4 ดังนั้น HotPDF จะ reject per-stream override บนไฟล์แบบนั้นด้วย error ที่ชัดเจน แทนการเขียน /Crypt marker ที่ conforming reader ไม่มีทางให้ความหมายได้ ฝั่ง read ก็เช่นกัน encryption dictionary ที่มี /V ต่ำกว่า 4 จะล้างชื่อ loaded filter ทั้งสาม เพราะไม่มีข้อมูลให้รายงาน ยังมีขอบเขตอีกสามข้อที่บังคับใช้อย่างตั้งใจ

  • per-stream filter ที่ไม่ใช่ Identity บน document ที่เข้ารหัสแบบ public-key จะถูกปฏิเสธ เพราะ stream-specific policy ภายใต้ public-key handler ต้องมี stream-specific recipient envelope ที่ HotPDF ยัง emit ไม่ได้
  • public-key encrypted embedded file ที่ /EFF ต่างจาก effective /StmF จะถูกปฏิเสธด้วยเหตุผลเดียวกัน แทนการเขียนรูปแบบที่ไม่มีใคร decrypt ได้
  • AES-256 direct-file fast path ใช้ได้เฉพาะเมื่อ string, stream และ embedded file ทั้งหมด resolve ไปยัง crypt filter method เดียวกัน และไม่มี object ใดในไฟล์มี explicit /Crypt policy ที่ผสมกันหรือ plaintext metadata จะบังคับให้ fallback ไปยัง full object-graph path

ทั้งหมดนี้ไม่ใช่การตัดสินใจด้าน performance แต่เป็นจุดที่การเดาผิดจะสร้าง PDF ที่เปิดได้ใน viewer หนึ่ง แต่ fail ในอีก viewer และไม่ส่งสัญญาณใดให้ developer จนกว่าลูกค้าจะรายงาน การปฏิเสธที่ ConfigureCryptFilterDefaults หรือเวลา save มีต้นทุนเป็น exception หนึ่งครั้ง แต่ embedded file ที่ใช้ key ผิดอย่างเงียบ ๆ มีต้นทุนเป็น support cycle หากคุณสร้างซอฟต์แวร์ Delphi หรือ C++Builder ที่ผลิตหรืออ่าน encrypted PDF ไม่ว่าจะเป็น page content ที่ปล่อย plaintext บางส่วนแต่เข้ารหัส attachment, encrypted payload wrapper ของ PDF 2.0 หรือ interop กับไฟล์ที่คุณไม่ได้เลือก crypt filter policy เอง crypt filter API นี้อยู่ใน HotPDF Delphi PDF component รุ่นปัจจุบัน พร้อมเส้นทาง encryption, object stream และ incremental update ที่มันต่อยอด