Bài viết kỹ thuật

Crypt filter PDF trong Delphi: policy StmF, StrF, EFF

HotPDF Delphi PDF component triển khai mô hình crypt filter ISO 32000-1 §7.6.5 thành ba policy độc lập thay vì một switch: ConfigureCryptFilterDefaults gán riêng string filter /StrF, stream filter /StmF và embedded-file filter /EFF, SetStreamCryptFilter override một stream duy nhất, còn GetLoadedCryptFilterInfo báo cáo điều file đầu vào khai báo. Phần lớn bug interop PDF mã hóa nằm trong khoảng trống giữa ba policy này

Đây là failure khiến người ta phải đi xuống layer này. Một team phát hành document trong đó page content phải còn đọc được bởi downstream tool nhưng payload đính kèm thì không, nên họ đặt /EFF /StdCF và để /StmF /Identity. Acrobat mở bình thường. Một reader bên thứ ba conforming trả attachment về như ciphertext rác, vì /EFF là policy phía producer về filter áp dụng cho embedded file còn reader thông thường vẫn resolve stream không đánh dấu qua /StmF. Bản sửa không phải giá trị /EFF khác. Bản sửa là một filter /Crypt tường minh ngay trên embedded-file stream

Crypt filter layer thực sự kiểm soát gì?

Crypt filter nằm giữa encryption algorithm và object graph, quyết định object nào bị algorithm tác động chứ không quyết định algorithm hoạt động ra sao. Dictionary /CF trong encryption dictionary map name tới filter definition, mỗi definition có method /CFM, /Length tùy chọn và /AuthEvent. Ba entry cấp cao /StrF, /StmF/EFF chọn filter named nào áp dụng cho string, stream không có filter tường minh và embedded file. HotPDF cố ý giới hạn những gì built-in handler sẽ ghi. ConfigureCryptFilterDefaults chỉ nhận reserved name của handler đang dùng: Standard security handler emit /StdCF hoặc /Identity, public-key handler emit /DefaultCryptFilter hoặc /Identity, còn mọi giá trị khác raise EArgumentException ngay tại call site. Filter do producer bên ngoài ghi dưới name khác vẫn được giữ khi load, inspect và compatibility-rewrite, nên HotPDF conservative khi writer nhưng permissive khi reader. Hai guard nữa cũng được áp dụng: call raise EInvalidOpException sau khi document serialization bắt đầu và một lần nữa nếu document là incremental update, vì encryption policy không thể đổi giữa các revision của cùng file

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 được mã hóa, page stream plaintext, attachment được mã hóa
    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;

Có một constraint nên nói ngay từ đầu vì nó được check muộn và thường gây bất ngờ. Named crypt filter trong HotPDF yêu cầu document encryption là aes128, aes256 hoặc aesgcm. Configure filter policy trên RC4 k40 hoặc k128, validation pass lúc bật encryption sẽ raise thay vì âm thầm nâng key type. Đây cũng là lập trường của đường PDF encryption AES-256 trong Delphi: từ chối configuration mơ hồ thay vì đoán ý caller

Vì sao entry /Length có hai ý nghĩa khác nhau?

Vì spec định nghĩa nó theo hai unit khác nhau tùy security handler, và HotPDF phải tôn trọng cả hai. Trong crypt filter dictionary có /CFM/V2, entry /Length được biểu diễn bằng byte dưới Standard security handler và bằng bit dưới public-key handler. /Length trong encryption dictionary nằm cạnh /V (ISO 32000-1 §7.6.2) luôn tính theo bit. Đọc filter dictionary có /Length 16, bạn có key 128-bit trong file Standard-handler và một file bị reject trong public-key. HotPDF normalize điều này khi capture loaded configuration. Nó chỉ nhân /V2 /Length của filter với tám khi file không mã hóa public-key, fallback về /Length cấp document khi filter không có entry riêng và lưu kết quả vào THPDFCryptFilterInfo.KeyLengthBits. AESV2 bị ghim ở 128 bit, AESV3AESV4 ở 256 vì các method đó không cho thương lượng key size. Phần strict xuất hiện sau đó: chỉ /V2 40-bit và 128-bit được chấp nhận. Filter resolve thành length khác bị báo unavailable và operation thất bại, thay vì làm tròn lên 128 với giả định producer có ý 128. Normalize key length im lặng là cách phát hành một PDF giải mã được trên máy mình nhưng không ở đâu khác

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 và /StmF mặc định là Identity; /EFF mặc định theo /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 bảo đảm gì, và /Identity khác ra sao?

Chúng đi đến cùng một kết quả bằng hai con đường khác nhau, và trộn lẫn chúng sẽ làm hỏng lookup. Named filter có /CFM/None và named filter bỏ hoàn toàn /CFM đều có nghĩa filter này không mã hóa hay giải mã — HotPDF map entry bị thiếu thành None trước khi resolve, nên cả hai đều thành hcfmNone với key length ghi nhận bằng 0. /Identity khác về bản chất: đây là reserved name bypass lookup /CF hoàn toàn, nên document có thể tham chiếu /Identity mà không định nghĩa nó ở đâu trong /CF. PDF name phân biệt hoa thường, kéo theo một chi tiết implementation không thể thương lượng: không lookup crypt filter nào được phép case-insensitive. HotPDF resolve tên sub-dictionary /CF, entry /Length của filter và check /Type của stream bằng dictionary lookup phân biệt case. File định nghĩa /stdcf trong khi /StmF trỏ tới /StdCF là malformed, và coi hai key đó như một sẽ biến lỗi authoring có thể phát hiện thành key sai âm thầm áp dụng cho mọi stream trong document

Làm cho /EFF thực sự áp dụng trên embedded-file stream

Khi /EFF khác /StmF, embedded-file stream cần entry /Crypt đứng đầu trong /Filter và dictionary /DecodeParms tương ứng có /Namecùng vị trí trong array. HotPDF tính điều này theo từng stream lúc save: nó nhận diện /Type /EmbeddedFile, kế thừa embedded-file filter đã cấu hình và chỉ emit marker /Crypt tường minh khi name được kế thừa khác stream default hiệu dụng. Khi /EFF/StmF trùng nhau, không ghi marker vì reader sẽ resolve cùng filter. Vị trí trong array cũng quan trọng ngang cái tên. Khi đọc lại stream, HotPDF scan /Filter tìm entry /Crypt, ghi nhớ index rồi lookup đúng index đó trong array /DecodeParms để tìm /Name. /Crypt ở index 0 ghép với parameter ở index 1 sẽ resolve thành /Identity, không phải filter của bạn. Đây cũng là lý do writer pad parameter array bằng null khi stream trước đó có /Filter nhưng không có /DecodeParms: position phải luôn align

Bên dưới còn một cái bẫy sắc hơn. Nếu /Filter hoặc /DecodeParms hiện có là indirect object — thường gặp trong file do generator tạo, nơi một filter array được nhiều stream share — chèn /Crypt tại chỗ sẽ mutate shared filter graph và corrupt mọi stream khác trỏ vào nó. HotPDF resolve indirect object rồi clone thành direct object riêng của stream trước, xóa object number và generation number để indirect root gốc không bao giờ nằm bên trong array mới. Với stream đã dùng ASCIIHexDecode, kết quả serialize là /Filter [ /Crypt /ASCIIHexDecode ] cùng /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]. Kỷ luật về position tương tự chi phối mọi filter chain khác, kể cả những chain bạn đi qua khi extract image từ PDF đã load qua decode filter của chúng

// Editor đã giữ loaded document, ContentStream là
// THPDFStreamObject có /Filter là 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');

// Name rỗng sẽ xóa override và loại stale /Crypt
// cùng decode parameter ở lần save tiếp theo
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

Object stream có kế thừa policy /Encrypt của document không?

Không, và giả định là có là cách đáng tin cậy để tạo rác. Object stream phải theo policy /StmF thực tế hoặc marker /Crypt riêng của nó: chỉ sự hiện diện của dictionary /Encrypt không biến mọi container /ObjStm thành ciphertext. Document có /StmF /Identity sẽ có object stream plaintext dù string của nó được mã hóa hoàn toàn, còn decoder vẫn decrypt chúng sẽ đưa input chưa từng là deflate output vào inflate stage

Hệ quả với member object là phần đáng đọc kỹ. Theo ISO 32000-1 §7.5.7, string bên trong encrypted object stream đã là plaintext ngay khi container được decrypt, nên decrypt lần nữa sẽ thành double-decrypt. HotPDF ngăn việc đó bằng cách hỏi xem container của từng type-2 object có được mã hóa không và skip object khi có, ghi số skip vào XRefProbeDecryptObjStmSkips như bằng chứng trực tiếp guard đã kích hoạt. Khi container là plaintext, member string chưa được bảo vệ bởi gì, nên HotPDF materialize member rồi áp dụng /StrF cho từng member — keying theo đúng implementation là member object number và generation, không phải object number của /ObjStm chứa nó. Đảo ngược điều này trên file mixed-policy sẽ biến mọi string trong mọi compressed object thành noise. Quy tắc cấp container được nói thêm trong ghi chú về PDF object stream và incremental update

HotPDF từ chối đoán ở đâu

Semantics crypt filter không tồn tại dưới /V 4, nên HotPDF từ chối per-stream override trên file như vậy bằng lỗi tường minh thay vì ghi marker /Crypt mà reader conforming nào cũng không coi trọng. Phía read cũng vậy: encryption dictionary có /V dưới 4 sẽ xóa cả ba loaded filter name vì ở đó không có gì để báo cáo. Ba boundary khác cũng được ép có chủ ý:

  • Per-stream filter khác Identity trên document mã hóa public-key bị từ chối, vì policy riêng theo stream dưới public-key handler cần recipient envelope riêng cho stream mà HotPDF chưa emit
  • Embedded file mã hóa public-key có /EFF khác /StmF hiệu dụng cũng bị từ chối vì cùng lý do, thay vì ghi một hình dạng không ai giải mã được
  • Đường fast path direct-file AES-256 chỉ áp dụng khi string, stream và embedded file đều resolve về cùng method crypt filter và không object nào trong file có /Crypt tường minh; mixed policy hoặc metadata plaintext buộc fallback sang full object-graph path

Không giới hạn nào trong số này là quyết định về performance. Chúng đánh dấu nơi một phỏng đoán sai tạo ra PDF mở được ở viewer này, hỏng ở viewer khác và không cho developer tín hiệu nào cho tới khi customer báo lỗi. Từ chối tại ConfigureCryptFilterDefaults hoặc lúc save chỉ tốn một exception; embedded file bị key sai âm thầm sẽ tốn cả vòng support. Nếu bạn xây phần mềm Delphi hoặc C++Builder tạo hay đọc PDF mã hóa — page content plaintext có chọn lọc cùng attachment mã hóa, payload wrapper mã hóa PDF 2.0 hoặc interop với file có crypt filter policy bạn không chọn — crypt filter API mô tả ở đây có trong HotPDF Delphi PDF component hiện tại, cùng các path encryption, object stream và incremental update mà nó xây dựng