您用一個本意是「什麼都允許」的遮罩加密了一份報告,$FFFFFFF0,結果 Acrobat 的安全性頁籤把列印與變更文件列為不允許。/P 是從位元 1 起算的無號 32 位元旗標字,所以那個遮罩清掉了列印(位元 3)與修改(位元 4);全部允許的值是 $FFFFFFFC,也就是 PDFium Component 裡 TPdfEncryptOptions 的起點
/P 旗標字是怎麼排的
只有八個位置有意義,其餘都是保留位,而編號從 1 開始。ISO 32000-1 §7.6.3.2 說 P「shall be interpreted as an unsigned 32-bit quantity」,從低位端的 1 編到高位端的 32,並要求符合規範的讀取器忽略 3、4、5、6、9、10、11、12 以外的所有位元。ISO 32000-2 §7.6.4.2 訂正了表 22,把保留位置講明白:位元 1–2 必須是 0,位元 7–8 必須是 1,而 revision 3 及以後位元 13–32 必須是 1。PDF 2.0 也讓位元 10 退役,讀取器現在忽略它,寫入器則一律設成 1 給舊版讀取器看;無障礙的例外條款移到位元 5,所以當內容送進輔助技術時,讀取器應該視同允許複製。下面每一列都可以用同一個公式算出來:位元 n 的遮罩是 1 shl (n - 1),而 TPdfPermission 對應到這個字的方式也一樣
| 位元 | 遮罩 | 意義(revision 3 及以後) | TPdfPermission |
|---|---|---|---|
| 1–2 | $00000003 | 保留,必須為 0 | 無 |
| 3 | $00000004 | 列印,品質取決於位元 12 | permPrint |
| 4 | $00000008 | 以位元 6、9、11 以外的方式修改內容 | permModify |
| 5 | $00000010 | 複製或擷取文字與圖形 | permCopy |
| 6 | $00000020 | 新增或修改註解、填寫欄位;位元 4 同時設立時可建立欄位 | permAnnotate |
| 7–8 | $000000C0 | 保留,必須為 1 | 無 |
| 9 | $00000100 | 填寫既有表單欄位,含簽名字段,即使位元 6 未設立 | permFillForms |
| 10 | $00000200 | 無障礙擷取;PDF 2.0 已不使用,寫入時一律為 1 | permAccessibility |
| 11 | $00000400 | 組合:插入、旋轉或刪除頁面,建立書籤項目與縮圖 | permAssemble |
| 12 | $00000800 | 以忠實的高品質呈現列印 | permPrintHighQuality |
| 13–32 | $FFFFF000 | 保留,必須為 1(ISO/TS 32004 規定位元 13,見下文) | 無 |
$FFFFFFF0 這個遮罩,是有人讀到「低位是保留位」就乾脆把整個 nibble 清掉求保險的結果。保留區只有位元 1–2,遮罩 $3;清掉 $F 連 $4 與 $8 也一起抹掉,那正是列印與修改,任何理會旗標的檢視器都會兩項全拒。鏡像的錯誤則是從零往上湊遮罩:把 $4、$10 與 $800 OR 起來,得到一個小小的正數 /P,位元 7–8 與 13–32 都是零。大多數讀取器打開這種檔案什麼都不說,因為規範叫它們忽略保留位;但對 revision 3 及以後來說這個值不合規範,嚴格的檢查器有權抱怨。第三種變形出現在從零起算位元的程式碼裡,「位元 3」變成 $8,程式想開放列印卻開放了修改。這些在存檔時都不會報錯,權限就是跟作者原本的意思不一樣
/P -4 跟 /P 4294967292 是同一個值嗎?
是。同樣的 32 個位元,兩種拼法。PDF 整數物件是有號的,而表 22 上方那條註記指出:因為高位保留位是 1,這個整數會寫成負數;對 revision 3 及以後來說,-4 就是慣用的全部允許值。把值放在無號變數裡的產生器則寫 4294967292,兩種拼法在真實檔案裡都見得到。規範在自己的金鑰推導裡就講定了哪種讀法才對:ISO 32000-1 §7.6.3.3 演算法 2 的步驟 (d) 把 P 轉成 32 位元無號二進位數,並把四個位元組以低位元組在前的方式送進 MD5,所以對 revision 2 到 4 而言,-3904 與 4294963392 產生的檔案金鑰一模一樣。把 /P 存進有號 32 位元 Integer 的解析器會把無號拼法當溢位拒掉;變數換成 Cardinal 之後,換負數拼法失敗;而拿 token 文字去跟存下來的值相比的程式碼,則會把同一份文件當成兩份。請把 token 讀進 Int64,接受 Low(Integer) 到 High(Cardinal) 之間的任何值,並在任何比對之前遮成 32 位元。PDFium Component 自己的解密程式就是這樣讀 revision 2 到 7 的 /P,寫入端則輸出規範註記所示的有號形式。程式碼片段裡的保留位報告停在位元 14,因為位元 13 為零正是 ISO/TS 32004 標記一份帶 PDF MAC 的檔案的方式
uses
System.SysUtils;
// 把自己字典解析器拿到的 /P token 文字正規化
function TryNormalizePermissions(const Token: string;
out Mask: Cardinal): Boolean;
var
Value: Int64;
begin
Result := TryStrToInt64(Token, Value) and
(Value >= Low(Integer)) and (Value <= High(Cardinal));
if Result then
Mask := Cardinal(Value and $FFFFFFFF);
end;
function DescribeReservedBits(Mask: Cardinal): string;
begin
Result := '';
if (Mask and $00000003) <> 0 then
Result := Result + 'bits 1-2 set; ';
if (Mask and $000000C0) <> $000000C0 then
Result := Result + 'bits 7-8 clear; ';
// 位元 13($1000)故意略過:ISO/TS 32004 清掉它來要求 PDF MAC
if (Mask and $FFFFE000) <> $FFFFE000 then
Result := Result + 'bits 14-32 not all set; ';
end;
// 預期:兩次呼叫都得到 $FFFFFFFC,保留位報告為空
// TryNormalizePermissions('-4', M1);
// TryNormalizePermissions('4294967292', M2);
從 TPdfPermissions 集合組出遮罩
從每個保留位都停在它該有的值開始,只 OR 進您真的要給的權限,這樣漏掉某項權限最多只會變成拒絕。FPdfEncrypt 單元裡的 TPdfEncryptOptions.Default 已經帶著 $FFFFFFFC,也就是全部允許,從它身上清掉一兩個位元,用來做快速限制很方便。如果政策是要列出收件者可以做什麼,寫成集合比較好讀,下面那個常數陣列就對齊 TPdf 解這個字時用的位元位置。不論呼叫端傳什麼,位元 10 一律強制設上。ISO 32000-2 要求寫入器永遠設它,PDF 2.0 的讀取器反正也忽略它,留著零只會冒著在舊軟體裡擋掉螢幕閱讀器的風險
uses
System.SysUtils, PDFium, FPdfEncrypt;
const
PermissionBit: array[TPdfPermission] of Cardinal = (
$0004, // permPrint 位元 3
$0008, // permModify 位元 4
$0010, // permCopy 位元 5
$0020, // permAnnotate 位元 6
$0100, // permFillForms 位元 9
$0200, // permAccessibility 位元 10
$0400, // permAssemble 位元 11
$0800); // permPrintHighQuality 位元 12
// 位元 7-8 與 13-32 設為 1;位元 1-2 與所有權限位元為 0
ReservedOnes = Cardinal($FFFFF0C0);
function PermissionsToMask(Allowed: TPdfPermissions): Cardinal;
var
P: TPdfPermission;
begin
Result := ReservedOnes or PermissionBit[permAccessibility];
for P in Allowed do
Result := Result or PermissionBit[P];
end;
procedure SaveFillAndPrintCopy(const Source, Target: string;
const OwnerPassword: AnsiString);
var
Pdf: TPdf;
Opts: TPdfEncryptOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := Source;
Pdf.Active := True;
if not Pdf.Active then
raise Exception.CreateFmt('Could not open %s', [Source]);
Opts := TPdfEncryptOptions.Default; // 這裡 Revision = erR5
Opts.UserPassword := ''; // 任何人都能開啟這個檔案
Opts.OwnerPassword := OwnerPassword; // 守護這組權限設定
Opts.Revision := erR6;
Opts.Permissions := PermissionsToMask(
[permPrint, permPrintHighQuality, permFillForms]);
// 預期:/P -1084 = 4294966212 ($FFFFFBC4)
Writeln('/P ', Integer(Opts.Permissions), ' = ', Opts.Permissions);
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('Encrypted save failed');
finally
Pdf.Free;
end;
end;
預期結果:輸出檔的 Encrypt 字典帶著 /P -1084,理會旗標的檢視器會提供全品質列印與表單填寫,同時把內容編輯、複製、註解與頁面組合變灰。位元 13 是寫入器替您管的一個位置。EnableIntegrityProtection 留在 False 時,不論您的遮罩怎麼寫,SaveAsEncrypted 都會設上位元 13;設成 True 時,寫入器會在 /P 與 /Perms 兩處都把位元 13 清掉。其餘保留位仍然是您的責任——所以起點是 $FFFFF0C0。範例選 erR6,因為 R6 是 ISO 32000-2 標準化的 AES-256 revision;預設記錄帶的是 erR5
/Perms:AES-256 檔案裡 /P 的加密副本
在 revision 2 到 4,權限字是焊在金鑰上的:改掉 /P 的任何一個位元,MD5 的輸入就變了,推出來的金鑰不對,什麼都解不開。AES-256 檔案打斷了這條連結。ISO 32000-2 §7.6.4.3.3 的演算法 2.A 從 /UE 或 /OE 解開檔案加密金鑰,P 完全不參與那次推導,所以單看明文的 /P,誰都能隨便改。/Perms 補上了這個缺口。演算法 10(§7.6.4.4.9)填出一個 16 位元組區塊,再用檔案金鑰以 AES-256 ECB 模式加密:
- 位元組 0–3:權限字,低位元組在前,由位元組 4–7 延伸成 64 位元,那四個都是
$FF - 位元組 8:ASCII 的
T或F,與/EncryptMetadata相符 - 位元組 9–11:ASCII 標記
adb - 位元組 12–15:讀取器會忽略的隨機資料
演算法 2.A 的步驟 (f) 會解開這個區塊,驗證 adb,並要求位元組 0–3 與 P 相符——是 shall,不是 should。演算法 13(§7.6.4.4.12)用較軟的 should 重覆標記與權限檢查,再加一項:位元組 8 要對上 EncryptMetadata。PDFium Component 在自家程式碼打開加密層的每一處都採嚴格讀法:AESV4 解密、PDF MAC 驗證,以及寫回加密檔的增量儲存。使用者或擁有者密碼解開檔案金鑰之後,那段程式碼會把四個權限位元組逐一比對正規化後的 /P,檢查 $FF 填充、T/F 位元組與標記,只要有一處不同就以「PDF encryption permissions validation failed」讓載入失敗,因為不一致代表有人在加密後改過字典,兩份副本都不能照字面採信
EncryptMetadata 的比對,在您自己寫的讀取器裡值得多留意。Pos('/EncryptMetadata false', Dict) > 0 這種捷徑會漏掉中間有兩個空格的 /EncryptMetadata false、鍵與值之間夾了換行的、或者中間夾了註解的——這些都是合法 PDF 語法。讀取器於是假設預設的 true,在完全合法的 /Perms 裡看到 F,然後把檔案拒掉。一份合法檔案,被拒絕。請把鍵與值當 token 解析,非布林值就當畸形,別拿預設值頂上
ISO/TS 32004 給了位元 13 一個任務:它為零時,文件帶著一份讀取器必須檢查的 PDF MAC。MAC token 放在 trailer 的 /AuthCode 字典裡,那是不加密的,所以只去找 /AuthCode 的檢查會認定一份把它刪掉的檔案從來沒做過完整性保護。連 /KDFSalt 一起刪掉也藏不住這個要求,因為被清掉的位元活在 /P 裡,而 /P 是由 /Perms 認證的。因此 PDFium Component 的 AESV4 解密把「位元 13 為零」或「殘留 /KDFSalt 卻沒有 /AuthCode」都當成 MAC 被剝除,直接失敗;而關掉完整性保護時,寫入器會把位元 13 設上,讓它的輸出永遠不會宣告一個自己並沒有的 MAC。那個 token 背後的金鑰階層與位元組範圍,屬於 PDF 2.0 的 AES-GCM 加密與 ISO/TS 32004 MAC;而在這一切跑起來之前解開檔案金鑰的迭代密碼雜湊,見R6 密碼雜湊與其迭代次數
Permissions 還是 UserPermissions:UI 該讀哪一個?
要決定這次工作階段可以做什麼,讀 Permissions;要報告檔案給一般收件者什麼,讀 UserPermissions。TPdf.Permissions 包的是 PDFium 的 FPDF_GetDocPermissions,描述這份已開啟文件實際被授予的存取權,取決於當初是哪個密碼解開它。TPdf.UserPermissions 包的是 FPDF_GetDocUserPermissions,不論用哪個密碼,都持續回報 /P 裡給使用者的授權,所以用擁有者密碼開啟時兩者會不一樣。兩者都是 LongWord;GrantedPermissions 與 UserGrantedPermissions 把它們解成 TPdfPermissions 集合,HasPermission 與 UserHasPermission 測單一成員。SecurityHandlerRevision 告訴您檔案到底有沒有安全處理器。下面這段程式把原始字的兩種整數視角印在解碼後的集合旁邊,是看出某個檔案「就是印不出來」原因的快捷方式
uses
System.SysUtils, System.TypInfo, PDFium;
procedure DumpPermissions(const FileName, Password: string);
var
Pdf: TPdf;
Mask: LongWord;
Granted: TPdfPermissions;
P: TPdfPermission;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FileName;
Pdf.Password := Password; // 必須在 Active 之前指定
Pdf.Active := True;
if not Pdf.Active then
raise Exception.CreateFmt('Could not open %s', [FileName]);
if Pdf.SecurityHandlerRevision <= 0 then
begin
Writeln('Not encrypted: no /P to decode');
Exit;
end;
Mask := Pdf.UserPermissions;
Granted := Pdf.UserGrantedPermissions;
Writeln(Format('R%d /P as Integer %d, as Cardinal %u ($%.8x)',
[Pdf.SecurityHandlerRevision, Integer(Mask), Mask, Mask]));
for P := Low(TPdfPermission) to High(TPdfPermission) do
Writeln(Format(' %-22s %s',
[GetEnumName(TypeInfo(TPdfPermission), Ord(P)),
BoolToStr(P in Granted, True)]));
if not Pdf.HasPermission(permCopy) then
Writeln(' this session may not copy text');
finally
Pdf.Free;
end;
end;
// 前面寫出的檔案預期結果:
// R6 /P as Integer -1084, as Cardinal 4294966212 ($FFFFFBC4)
// permPrint True, permModify False, permCopy False, ...
權限的執行是宿主應用程式的事
不論旗標怎麼寫,PDFium 都會算圖、擷取文字、把頁面點陣化。引擎只負責報告權限,政策留給呼叫端。手上握有使用者密碼的人就握著解密後的內容——使用者密碼是空的話,那就是所有人——所以機密性只能來自讀取器手上沒有的使用者密碼。檢查請放進每個命令處理常式裡(複製、匯出、刪頁、列印),而不是把工具列按鈕藏起來,因為快速鍵、右鍵選單與自動化都能繞過工具列,這種分層做法和Delphi 應用程式的安全 PDF 預覽窗格裡一樣。比如列印命令,在送出任何東西之前先測 permPrint
在 AES-256 檔案裡,明文的 -1084 什麼都證明不了,直到 /Perms 的前四個解密位元組讀出來是 C4 FB FF FF。這種逐位元組的比對,跑在 PDFium Component 的 AESV4、PDF MAC 與加密增量儲存程式碼裡