HotPDF Delphi PDF 元件將 ISO 32000-1 第 7.6.5 節的加密過濾器模型實作為三個相互獨立的策略,而不是一個總開關:ConfigureCryptFilterDefaults 分別設定字串過濾器 /StrF、串流過濾器 /StmF 和嵌入檔案過濾器 /EFF,SetStreamCryptFilter 覆寫單一串流,GetLoadedCryptFilterInfo 則回報輸入檔案宣告的內容。大多數加密 PDF 互通性問題都藏在這三者之間的縫隙裡
下面這種失敗會把人帶到這一層。團隊交付一份文件,頁面內容必須保持可讀以供下游工具使用,但附件不能保持可讀,於是設定 /EFF /StdCF 並保留 /StmF /Identity。Acrobat 可以正常開啟。一個符合規範的第三方閱讀器卻回傳密文垃圾,因為 /EFF 是生產者端關於嵌入檔案使用哪個過濾器的策略,而通用閱讀器仍會透過 /StmF 解析沒有明確標記的串流。修復不是換一個 /EFF 值,而是在嵌入檔案串流本身加入明確的 /Crypt 過濾器
加密過濾器層實際控制什麼
加密過濾器位於加密演算法和物件圖之間,決定演算法會接觸哪些物件,而不是決定演算法如何工作。加密字典中的 /CF 字典把名稱映射到過濾器定義,每個定義帶有 /CFM 方法、可選的 /Length 和 /AuthEvent。頂層的 /StrF、/StmF 和 /EFF 再選擇這些命名過濾器中的哪個套用於字串、沒有明確過濾器的串流以及嵌入檔案。HotPDF 有意限制內建處理器會寫入的內容。ConfigureCryptFilterDefaults 只接受目前處理器的保留名稱:標準安全處理器輸出 /StdCF 或 /Identity,公開金鑰處理器輸出 /DefaultCryptFilter 或 /Identity,其他名稱會在呼叫點擲出 EArgumentException。外部生產者以其他名稱寫入的過濾器,在載入、檢查和相容性重寫路徑上仍會被保留,因此 HotPDF 作為寫入器是保守的,作為讀取器則是寬容的。還有兩個保護條件:文件序列化開始後呼叫會擲出 EInvalidOpException,文件處於增量更新時也會擲出,因為同一檔案的不同修訂版本之間不能改變加密策略
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;
// 字串加密,頁面串流明文,附件加密
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;
有一項限制需要提前說明,因為它會在較晚階段檢查並讓人措手不及。HotPDF 中的命名加密過濾器要求文件加密方式為 aes128、aes256 或 aesgcm。在 RC4 k40 或 k128 之上設定過濾器策略時,加密啟用時執行的驗證階段會直接擲出錯誤,而不是悄悄提升金鑰類型。這與Delphi 中的 AES-256 PDF 加密路徑採取相同的設計立場:拒絕含糊設定,而不是猜測呼叫方的意圖
/Length 項目為什麼有兩種含義
因為規範根據安全處理器定義了兩種不同單位,HotPDF 必須同時遵守。在 /CFM 為 /V2 的加密過濾器字典中,標準安全處理器使用位元組表示 /Length,公開金鑰處理器則使用位元表示。與 /V 同層的加密字典 /Length(ISO 32000-1 第 7.6.2 節)始終使用位元。讀取帶有 /Length 16 的過濾器字典時,在標準處理器檔案中它代表 128 位元金鑰,在公開金鑰檔案中則會被拒絕。HotPDF 會在擷取載入設定時完成正規化:只有當檔案不是公開金鑰加密時,才將 /V2 過濾器的 /Length 乘以八;過濾器缺少自身值時回退到文件層級 /Length,並把結果存入 THPDFCryptFilterInfo.KeyLengthBits。AESV2 固定為 128 位元,AESV3 和 AESV4 固定為 256 位元,因為這些方法沒有可協商的金鑰大小。嚴格性體現在下一步:只接受 40 位元和 128 位元的 /V2。如果過濾器解析出的長度是任何其他值,系統會回報不可用並使操作失敗,而不是假定大多數生產者其實想要 128 位元後直接取整。靜默正規化金鑰長度,正是讓檔案在你的機器上能解密、在其他地方卻不能解密的方式
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 預設為 Identity;/EFF 預設為 /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 又有何不同
它們透過不同路徑得到相同結果,把二者混為一談會破壞查找。命名過濾器的 /CFM 為 /None,以及完全省略 /CFM 的命名過濾器,都表示該過濾器不進行加密或解密——HotPDF 會在解析前把缺失項目映射到 None,所以二者最後都落到 hcfmNone,記錄的金鑰長度為零。/Identity 在性質上不同:它是繞過 /CF 查找的保留名稱,因此文件可以引用 /Identity,而不必在 /CF 中定義它。PDF 名稱區分大小寫,這使得另一個實作細節不可妥協:任何加密過濾器查找都不能不區分大小寫。HotPDF 對 /CF 子字典名稱、過濾器 /Length 項目以及串流的 /Type 檢查,都使用區分大小寫的字典查找。檔案定義了 /stdcf,而 /StmF 指向 /StdCF 時,檔案就是格式錯誤;把這兩個鍵當成相同鍵,會把可檢測的撰寫錯誤變成靜默地對文件中每個串流套用錯誤金鑰
讓 /EFF 在嵌入檔案串流上真正生效
當 /EFF 與 /StmF 不同,嵌入檔案串流的 /Filter 中需要一個明確置於開頭的 /Crypt 項目,並且 /DecodeParms 字典陣列中同一位置要有攜帶 /Name 的相符字典。HotPDF 在儲存時按串流計算:它偵測 /Type /EmbeddedFile,繼承設定的嵌入檔案過濾器,只有當繼承名稱不同於有效串流預設值時才發出明確的 /Crypt 標記。當 /EFF 與 /StmF 一致時不會寫標記,因為閱讀器無論如何都會解析出同一個過濾器。陣列位置與名稱同等重要。HotPDF 讀回串流時,會掃描 /Filter 中的 /Crypt 項目並記錄其索引,再到 /DecodeParms 陣列的同一索引查找 /Name。索引 0 的 /Crypt 與索引 1 的參數配對時,解析結果是 /Identity,而不是你的過濾器。這也是為什麼當串流此前有 /Filter 卻沒有 /DecodeParms 時,寫入器會用 null 填充參數陣列:位置必須保持對齊
下面還有一個更尖銳的陷阱。如果現有的 /Filter 或 /DecodeParms 是間接物件——在多個串流共用一個過濾器陣列的生成器檔案中很常見——直接插入 /Crypt 會修改共用的過濾器圖,破壞所有指向它的其他串流。HotPDF 會解析間接物件,先將其複製成串流私有的直接物件,清除物件編號和世代編號,因此原來的間接根不會被嵌入新陣列。對先前使用 ASCIIHexDecode 的串流,序列化結果是 /Filter [ /Crypt /ASCIIHexDecode ],並帶有 /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]。同樣的位置紀律也適用於其他所有過濾器鏈,包括你在透過解碼過濾器從已載入 PDF 擷取影像時遍歷的那些過濾器鏈
// Editor 已經持有一份已載入文件,ContentStream 是一個 THPDFStreamObject,
// 其 /Filter 是間接的 /ASCIIHexDecode 名稱
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');
// 空名稱會清除覆寫,並在下一次儲存時移除過期的 /Crypt
// 項目及其解碼參數
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');
物件串流會繼承文件的 /Encrypt 策略嗎
不會,假設它們會繼承,是產生垃圾的可靠方式。物件串流必須遵循實際的 /StmF 策略,或遵循自身明確的 /Crypt 標記;僅僅存在 /Encrypt 字典,不會讓每個 /ObjStm 容器都變成密文。/StmF /Identity 的文件擁有明文物件串流,即使其中的字串已完全加密;錯誤地再次解密這些物件串流,就會把從未經過 deflate 的輸入送進解壓階段
成員物件的後果值得再讀一遍。按照 ISO 32000-1 第 7.5.7 節,已加密物件串流內的字串在容器本身解密後已經是明文,再次解密會變成雙重解密。HotPDF 透過查詢每個類型 2 物件的容器是否被加密來防止這種情況;如果是已加密容器,就跳過該物件,並將跳過次數累計到 XRefProbeDecryptObjStmSkips,作為保護邏輯確實觸發的直接證據。如果容器是明文,成員字串從未被任何內容覆蓋,HotPDF 就會實例化這些成員,並分別套用 /StrF——按照實作的真實方式,金鑰使用的是成員物件編號和世代編號,而不是包含它的 /ObjStm 物件編號。對混合策略檔案反過來處理後,每個壓縮物件中的每個字串都會解碼成雜訊。相關容器層級規則還在PDF 物件串流與增量更新的說明中進一步討論
HotPDF 拒絕猜測的邊界
低於 /V 4 的檔案不存在加密過濾器語意,因此 HotPDF 會以明確錯誤拒絕任何逐串流覆寫,而不是寫入任何符合規範的閱讀器都不會理會的 /Crypt 標記。讀取端也一樣:/V 低於 4 的加密字典會清除三個已載入過濾器名稱,因為那裡沒有可回報的內容。系統還會有意執行三個邊界限制
- 公開金鑰加密文件上的非
Identity逐串流過濾器會被拒絕,因為公開金鑰處理器下的串流層級策略需要 HotPDF 尚未產生的串流層級接收者封裝 - 當公開金鑰加密嵌入檔案的
/EFF與有效/StmF不同時,也會因相同原因被拒絕,而不是寫出一個誰也無法解密的形態 - AES-256 直接檔案快速路徑只在字串、串流和嵌入檔案都解析到同一種加密過濾器方法,且檔案中沒有物件帶有明確
/Crypt時適用;混合策略或明文中繼資料會強制回退到完整物件圖路徑
這些都不是效能決策,而是標記錯誤猜測會產生什麼樣的檔案:它可能在一個檢視器中開啟,在另一個檢視器中失敗,直到客戶回報問題前都不給開發者任何訊號。在 ConfigureCryptFilterDefaults 或儲存時擲出例外,只需要付出一次例外處理成本;靜默地為嵌入檔案使用錯誤金鑰,則會付出一個支援週期。如果你建構 Delphi 或 C++Builder 軟體來產生或消費加密 PDF——選擇性保持頁面內容明文並加密附件、使用 PDF 2.0 加密載荷封裝,或與加密策略並非由你選擇的檔案互通——這裡介紹的加密過濾器 API 已包含在目前的 HotPDF Delphi PDF 元件中,並與它所依賴的加密、物件串流和增量更新路徑一起提供