權限旗標 (permission flag) 並不是一種安全機制。表示「禁止複製」的位元,和密碼學機制一樣位在同一個 /Encrypt 字典內部,這賦予了它一種其實並不具備的強制執行氛圍;而當您將這兩者視為同一件事的那一刻,您的稽核就開始產生錯誤的答案了。對一份 PDF 唯一值得提出的問題並不是「它是否加密了」。它應該更具體也更困難:使用哪種演算法、哪個安全處理常式修訂版 (security handler revision)、設定了兩個密碼中的哪一個、宣告了哪些權限位元,以及加密實際觸及了檔案的哪些部分。一份檔案可以在形式上是加密的,但在實務上卻是公開的。它可以拒絕被讀取,卻將其中繼資料 (metadata) 留在明文 (plaintext) 中。它可以在任何檢視器都能自由忽略的旗標中鎖定列印功能。稽核一份 PDF 意味著要將所有這些分開解析;而 PDFlibPas,即 losLab 為 Delphi 與 C++Builder 提供的 PDF 引擎,透過扁平的整數控制代碼 API 與具型別的類別層,將它們全數公開了出來
/Encrypt 字典實際記錄的內容
ISO 32000-1 §7.6 透過少數幾個字典項目定義了文件安全性,而 PDFlibPas 在 TPDFEncryption 記錄 (record) 中,一對一地鏡像對應了它們。篩選器版本 V 與修訂版 R 選擇了演算法家族。Length 乘載了金鑰大小。權限位元位於 P,擁有者與使用者密碼驗證字串位於 O 與 U(AES-256 增加了 OE 與 UE),一個 EncryptMetadata 旗標緊隨其側,另外還有三個欄位分別命名了套用於字串、串流與內嵌檔案的密碼篩選器 (crypt filters)
這個記錄的價值在於,它不為您解釋任何東西。它交回原始字典讓您自行得出結論,這正是稽核所需要的。加密內含明文的案例會出現在 StringFilterIdentity 與 StreamFilterIdentity 中:當兩者任一為真時,對應的資料將原封不動地通過 Identity 篩選器,無論文件的加密狀態為何。如果掃描器停留在「存在 /Encrypt 字典」,當檔案的字串與串流實際上是明文時,它就會將這種檔案稱為受保護的。同樣的細微差別也主導著中繼資料。當 EncryptMetadata 為假時,XMP 封包對任何索引器 (indexer) 都能維持可讀,但頁面內容則否;當您的路由規則是根據標題或作者欄位來決定時,這一點便值得立即了解
使用扁平 API 進行簡短的安全探測
對於大多數的管線 (pipelines),四個扁平呼叫就能回答日常的問題。LoadFromFile 在成功時傳回 1,一旦文件開啟,加密檢查器 (encryption inspectors) 就會針對其解密狀態進行報告:
var
PDF: TPDFlib;
begin
PDF := TPDFlib.Create;
try
if PDF.LoadFromFile('contract.pdf', UserPassword) <> 1 then
raise Exception.Create('Open failed: wrong password or damaged file');
Writeln('status : ', PDF.EncryptionStatus); // decrypted / encrypted / unknown
Writeln('algorithm : ', PDF.EncryptionAlgorithm); // RC4 vs AES family
Writeln('strength : ', PDF.EncryptionStrength); // key length class
Writeln('owner pw? : ', PDF.CheckPassword(CandidatePassword));
finally
PDF.Free;
end;
end;
CheckPassword 比其單行的簽章所暗示的還要重要。PDF 定義了兩個權力不等的密碼。開啟檔案完全需要使用者密碼 (user password)。而擁有者密碼 (owner password) 則授予完整權限並覆寫每個權限位元。無論是哪一種方式,磁碟上的位元組都是完全相同的;但是在擁有者密碼下開啟的對話 (session),可以做使用者密碼對話不能做的事;所以如果一份稽核沒有記錄呈現了哪種憑證,它就只記錄了一半的真相。類別層使得這種區別可被查詢。TPDFDocument.HasUserPassword 與 HasOwnerPassword 報告檔案的要求;而 IsUserPassword 與 IsOwnerPassword 則報告實際開啟當前對話的是哪一個密碼。請記錄下這個事實。永遠不要記錄密碼值本身
強度階梯:為何「AES-256」有兩種含義
扁平的 Encrypt 與 EncryptFile 函式接受一個具五個有意義數值的整數 Strength(強度):0 代表 40 位元 RC4,1 代表 128 位元 RC4,2 代表從 Acrobat 7 起可讀取的 128 位元 AES,3 代表 Acrobat 9 引入的 256 位元 AES,而 4 代表 Acrobat X 及更新版本所要求的 256 位元 AES
有趣的地方在於 3 與 4 都標示為 AES-256,但它們並不是同一種機制。Strength 3 對應於安全處理常式修訂版 5(R5),這是 Acrobat 9 所發布的過渡性設計,ISO 從未採用。Strength 4 對應於修訂版 6(R6),其金鑰衍生函式 (key-derivation function) 在 ISO 32000-2 中經過了強化與標準化。對於您今天建立的文件,沒有理由選擇 3 而不選 4。對於稽核而言,這個差距是決定性的:寫著「符合 ISO 32000-2 的 AES-256」的原則 (policy) 只有 R6 能滿足;而一個自稱為 AES-256 的 R5 檔案,在通過天真的強度檢查的同時,卻無法滿足該原則。類別層在名稱上將兩者分開,esAES256Bit 用於 R5,而 esAES256BitAcroX 用於 R6;此外,EncryptionAcroX 屬性則用一個布林值回答修訂版的問題
權限位元與其金鑰長度附屬細則
EncodePermissions 將八個旗標打包進 Encrypt 與 EncryptFile 預期的整數中。列印、複製、變更與加入註解構成了基本集合;填寫表單、為協助工具而複製 (copy-for-accessibility)、組裝與全品質列印則構成了延伸集合。附屬細則(也就是函式庫本身的加密範例直接聲明的部分)是,延伸的四個旗標只有在 128 位元強度以上才會生效。全品質列印旗標也適用相同的規則:清除它以強制低解析度列印,但 40 位元的文件會忽略您,因為該降級功能也需要 128 位元或更強的加密。將「僅限低解析度列印」原則編碼進 40 位元的檔案中,每個檢視器反正都會以全品質列印
更深層的問題是,誰來強制執行這些位元,而答案是沒有任何您可以信任的人。權限是給予合規讀取器 (conforming readers) 的指示,而非密碼學限制。無論是允許還是拒絕複製,解密金鑰都是相同的;所以一個鎖定的權限集合,只能讓誠實的檢視器保持誠實。如果一個讀取器選擇忽略這些位元,它完全不會面臨任何密碼學上的障礙。如果義務在於防止提取 (extraction) 而非不鼓勵它,那麼檔案就需要一個使用者密碼,工作流程也需要圍繞著它建立程序層級的控制;而稽核報告應該指明每個檔案實際處於這兩種體制下的哪一種,而不是將權限旗標視為一道鎖
設定原則並證明它生效
將加密套用至現有檔案不需要將它們載入物件樹 (object tree) 中。EncryptFile 在單一呼叫中處理輸入到輸出,而稽核迴圈 (audit loop) 則重新開啟結果以確認落在磁碟上的內容。隨附的加密範例遵循相同的「寫入後讀回」形狀:
var
PDF: TPDFlib;
R: Integer;
begin
PDF := TPDFlib.Create;
try
R := PDF.EncryptFile('in.pdf', 'out.pdf', 'owner-secret', 'user-secret', 4,
PDF.EncodePermissions(1, 0, 0, 0, // print allowed; copy/change/notes denied
0, 0, 0, 1)); // extended set: full-quality print only
if (R = 1) and (PDF.LoadFromFile('out.pdf', 'user-secret') = 1) then
begin
Writeln('algorithm = ', PDF.EncryptionAlgorithm);
Writeln('strength = ', PDF.EncryptionStrength);
Writeln('owner pw accepted: ', PDF.CheckPassword('owner-secret'));
end;
finally
PDF.Free;
end;
end;
在文件層工作的團隊能使用具型別的集合代替位元打包 (bit packing) 來進行相同的操作,這使得程式碼審查 (code review) 順利通過,而不必費力看清:
if not Doc.Encrypt('owner-secret', 'user-secret', esAES256BitAcroX,
[ppCanPrint], [ppCanPrintFull]) then
raise Exception.Create('加密失敗');
無論是哪種方式,讀回 (read-back) 步驟都不是可有可無的儀式。它能抓住那些否則在幾個月後才浮現在客戶機器上的部署錯誤:一個默默降級請求強度的舊函式庫建置版、一個因為目錄是唯讀而從未寫入的輸出路徑、一個引數順序錯誤的權限整數。這三者都能通過本機的冒煙測試,然後在現場失敗;而重新開啟輸出會將它們每一項轉變成您在建立檔案的執行期間就能看到的例外 (exception)。GetEncryptionFingerprint 會傳回一個可以與工作記錄一起儲存的精簡數值;因此,日後的比對就能在不重新開啟兩者的情況下,分辨出兩個輸出檔案是否共用相同的加密組態
值得編碼處理的稽核偽陽性
有幾種模式能可靠地將安全掃描器推向錯誤的結論,而每一種都來自於將一個由多個部分組成的問題,簡化成「是或否」的答案。Identity 密碼篩選器是最乾淨的例子。存在一個 /Encrypt 字典,檔案報告為已加密,然而字串與串流未經改變地通過 Identity 篩選器,因此真實內容是明文。在宣告任何東西為受保護之前讀取 StringFilterIdentity 與 StreamFilterIdentity 便是修正之道
中繼資料的分歧則更為微妙。EncryptMetadata 可以在兩個方向上與文件的其餘部分意見相左:留下一個加密的檔案但具有可讀的 XMP 封包,或者,較不常見的是,反過來。「檔案已加密」完全沒有說明它的中繼資料是否已加密;當索引器或路由規則想要取得標題時,這一點就很重要了。內嵌檔案增加了第三個軸向:PDF 允許專為附件設定的密碼篩選器,所以附件可以是一份原本公開的文件中唯一加密的部分,或者是一份加密文件中唯一明文的部分。將這三種篩選器指派,擷取為字串、串流與內嵌檔案各自獨立的欄位,那麼這些陷阱就一個也抓不住您。如果只儲存單一布林值,做出錯誤判斷只是時間早晚的問題
移除加密,以及為新檔案做選擇
稽核往往以決定剝除保護 (strip protection) 作結,而機制 (mechanics) 並不是這裡的障礙。DecryptFile(InputFileName, OutputFileName, Password) 可以在沒有完整載入的情況下寫出一份解密的副本;而針對已載入文件的 Decrypt,一旦檔案開啟,也能在記憶體中做同樣的事。兩者都需要有效的密碼;兩者都不會繞過密碼學。真正的關卡在於原則而非程式碼;所以請讓您的接收規則 (intake rules) 清楚聲明何時允許移除,並記錄授權該動作的密碼等級,因為技術步驟本身不會留下任何痕跡
對於新輸出的選擇,比五個 Strength 數值所暗示的還要狹窄。請使用 Strength 4,即 AES-256 修訂版 6;除非您必須在比 Acrobat X 更舊的檢視器中開啟檔案。Strength 2,即 AES-128,則是針對無法升級的衰老檢視器群的務實底線。位於 0 與 1 的 RC4 選項在那裡,是為了讓您可以讀取與稽核歷史封存檔,而不是讓您用它們來產生任何新東西;在 2026 年的設計中去拿取它們,就象徵著上游的需求已經陳腐了
加密狀態直接饋入簽署決策中,因為驗證並簽署文件的工作台 (workbench) 需要與這個稽核所依賴的相同的讀回紀律 (read-back discipline)。那個領域已在合規性與簽署工作台文章中涵蓋。當一個批次作業將 EncryptFile 橫跨套用到數千份大型文件時,大型 PDF 的直接存取指南會展示如何在它執行時將記憶體保持平穩。完整的加密 API 參考資料位於 PDFlibPas 產品頁面上