技術文章

PDF 權限旗標、/P 整數與 /Perms 副本

您用一個本意是「什麼都允許」的遮罩加密了一份報告,$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列印,品質取決於位元 12permPrint
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 已不使用,寫入時一律為 1permAccessibility
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,程式想開放列印卻開放了修改。這些在存檔時都不會報錯,權限就是跟作者原本的意思不一樣

Delphi 中 PDFium Component 的 PDF /P 權限旗標字佈局:保留位元 1-2、7-8 與 13-32,permPrint 與 permModify 等 TPdfPermission 位元,以及 $FFFFFFFC 與 $FFFFFFF0 兩個遮罩
用 $FFFFFFF0 清掉整個低位 nibble,列印與修改也一起沒了;而從零往上湊遮罩,會讓 revision 3 及以後的保留位維持在零

/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);
Delphi 中照 PDFium Component 的做法正規化 PDF /P 值:把 /P -4 與 /P 4294967292 讀進 Int64,從 Low(Integer) 到 High(Cardinal) 檢查範圍,再遮成 Cardinal $FFFFFFFC
有號 Integer 與 Cardinal 解析器各會拒掉 /P 的一種合法拼法,先讀成 Int64 再遮成 32 位元,兩種拼法就映到同一個值

從 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」讓載入失敗,因為不一致代表有人在加密後改過字典,兩份副本都不能照字面採信

PDFium Component 裡的 AES-256 /Perms 區塊:/P 權限字低位元組在前,$FF 填充,/EncryptMetadata 的 T 或 F 位元組與 adb 標記,由演算法 10 加密,並在演算法 2.A 與 13 下驗證
因為 AES-256 的檔案金鑰不再取決於 /P,解密後的 /Perms 位元組才足以證明明文權限字在加密之後沒被改過

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 與加密增量儲存程式碼裡