技術文章

Delphi 使用原始檔案金鑰開啟加密 PDF

PDF Library for Delphi 可以使用原始檔案加密金鑰開啟加密 PDF,而不需要密碼。DAOpenFileWithEncryptionKey 接受十六進位文字形式的金鑰,按照加密字典中既有的驗證器檢查它,並回傳唯讀 Direct Access 控制代碼;DAOpenFromStreamWithEncryptionKey 對呼叫方擁有的 TStream 執行相同操作。兩個入口都在 v3.496.0 中加入

這個情境很窄,但確實存在。取證工作從記憶體映像中復原出一把金鑰,卻沒有密碼。批次歸檔工作有一萬份文件,其檔案金鑰存放在託管資料庫中,因為原始 DRM 系統多年前就停止發放密碼。從已退役的權限管理產品移轉時,手上有金鑰材料,卻沒有其他東西。在這些情境中,手上的憑據是金鑰推導的輸出,而不是輸入,API 中任何密碼參數都無法接收它

為什麼檔案加密金鑰不是密碼

在 PDF 標準安全處理器中(ISO 32000-1 §7.6.3),密碼和檔案加密金鑰位於金鑰推導的兩端。處理器把密碼與 /O/P、檔案 ID 以及特定修訂版本的雜湊混合,產生檔案金鑰。把檔案金鑰塞進密碼參數,會得到被雜湊進另一個無意義值的無意義值,這正是它需要獨立入口而不能只是為 DAOpenFile 增加旗標的原因

原始金鑰可以注入的位置由修訂版本固定。修訂版本 2 到 4 仍會從檔案金鑰、物件編號、世代編號以及 AESV2 所需的 AES 鹽值推導獨立的物件金鑰,因此即使持有檔案金鑰,也完全不能跳過物件層級推導。修訂版本 5 到 7 對 AES-256 直接使用 32 位元組檔案金鑰,不再有物件層級步驟。兩者共有的唯一層就是檔案金鑰,因此這也是 PDF Library for Delphi 接受外部金鑰的唯一位置。如果你仍然有密碼,應該繼續使用普通路徑,讓密碼重試生命週期處理錯誤的第一次嘗試,因為原始金鑰入口會有意放棄密碼路徑保留的若干便利

DAOpenFileWithEncryptionKey 接受什麼輸入

只接受無前綴、無空白、長度為偶數的 ASCII 十六進位文字,解碼後的位元組數還必須與加密修訂版本完全一致。對於修訂版本 2 到 4,預期長度來自加密字典中的 /Length:它是 8 位元的倍數,範圍在 5 到 16 位元組之間;缺少 /Length 時預設使用 40 位元。對於修訂版本 5 到 7,長度固定為 32 位元組,不協商。0x 前綴、奇數位數、超過 64 個十六進位字元、Options 中的未知位元,或根本沒有加密的文件,都會產生相同結果:控制代碼為 0,LastErrorCode 設為 PDFLIB_ERROR_RAW_ENCRYPTION_KEY_INVALID,也就是 425。嚴格性正是設計目標。一個會裁剪空白並用零填補短輸入的寬鬆剖析器,很容易把截斷的剪貼簿內容變成憑據,然後在更難讀懂的地方才失敗

Var
  Lib: TPDFlib;
  FileHandle, PageRef: Integer;
Begin
  Lib:= TPDFlib.Create;
  Try
    // KeyHex 對 AES-128 R4 是 32 個十六進位字元,對 AES-256 R6 是 64 個
    FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex);
    If FileHandle= 0 Then
      Raise Exception.CreateFmt('raw key refused (error %d)', [Lib.LastErrorCode]);
    Try
      PageRef:= Lib.DAFindPage(FileHandle, 1);
      WriteLn(Lib.DAExtractPageText(FileHandle, PageRef, 0));
    Finally
      Lib.DACloseFile(FileHandle);
    End;
  Finally
    Lib.Free;
  End;
End;

其他失敗代碼仍然彼此區分,因此批次可以區分操作員錯誤和證據問題:檔案不存在時為 411,無法以讀取方式開啟時為 401,交叉參照結構損壞時為 409。所有與金鑰有關的情況都會有意合併為 425,因為一個會回報金鑰具體哪一部分錯誤的原始金鑰入口,本身就是一種 oracle

驗證實際上證明了什麼

PDF Library for Delphi 使用加密字典本身攜帶的驗證器,證明所提供的金鑰屬於這份文件,檢查方式取決於修訂版本。修訂版本 2 重新計算 32 位元組標準填補字串的 RC4 加密,並將全部 32 位元組與 /U 比較。修訂版本 3 和 4 將填補與檔案 ID 一起雜湊,執行 RC4 輪次以及 19 輪 XOR 推導輪次,再比較 /U 的前 16 位元組。修訂版本 5 到 7 使用零 IV 解密 16 位元組的 /Perms 字串,並同時檢查四件相互獨立的事情:小端序權限字與 /P 相符,位置 5 到 8 是四個 FF 位元組,加密中繼資料旗標是 TF,位置 10 到 12 是 adb 標記

只要驗證器存在但不相符,就無條件拒絕開啟。這一點值得直說,因為它正是整個功能所依賴的保證。還要注意驗證不代表什麼:它說明金鑰可以解密這份檔案,卻不表示任何人授權你使用它。從 /Perms 復原出的權限字是關於金鑰的證據,而不是授權;如果你想知道文件實際上宣稱允許什麼,那是加密與權限稽核流程的獨立工作。密碼端的正規化問題,例如非 ASCII AES-256 密碼的 SASLprep 處理,在這裡根本不會出現,因為沒有字串進入雜湊

PDF_RAW_KEY_ALLOW_UNVERIFIED 什麼時候生效

PDF_RAW_KEY_ALLOW_UNVERIFIED 只涵蓋一種情況:文件沒有可用驗證器,因為 /Perms 缺失或不是 16 位元組,或者 /U 太短而無法比較。它不能涵蓋失敗的證據。如果把 /Perms 中的一個十六進位數字改錯,再用設定了該選項的正確金鑰開啟,PDF Library for Delphi 仍然回傳 0 和 425。對完整驗證器傳入 32 位元組全零金鑰,即使設定了該選項,結果也一樣。該選項放寬的是沒有證據的情況,從不放寬與證據矛盾的情況。由於復原開啟和已驗證開啟代表不同的認知狀態,程式庫也會個別回報它們,而不是折疊進回傳值;DAGetEncryptionKeyValidation 接受開啟控制代碼,並回傳三個常數之一

  • PDF_RAW_KEY_VALIDATION_VERIFIED(1)表示驗證器存在且相符
  • PDF_RAW_KEY_VALIDATION_UNVERIFIED(2)表示只有在無法評估驗證器且呼叫方明確要求了該策略時,金鑰才會被接受
  • PDF_RAW_KEY_VALIDATION_NONE(0)是普通密碼開啟的控制代碼回傳的值
FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex);
If (FileHandle= 0)And
   (Lib.LastErrorCode= PDFLIB_ERROR_RAW_ENCRYPTION_KEY_INVALID) Then
  // 檔案中沒有驗證器?按明確的復原策略重試
  FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex,
    PDF_RAW_KEY_ALLOW_UNVERIFIED);
If FileHandle<> 0 Then
Begin
  Case Lib.DAGetEncryptionKeyValidation(FileHandle) Of
    PDF_RAW_KEY_VALIDATION_VERIFIED:
      Chain.Note('key verified against the encryption dictionary');
    PDF_RAW_KEY_VALIDATION_UNVERIFIED:
      Chain.Note('no verifier available: extraction is unattested');
  End;
End;

從設計上唯讀,以及誰擁有串流

原始金鑰檔案入口一律以 fmOpenRead or fmShareDenyWrite 開啟來源,並將整個 Direct Access 鏈標記為唯讀,因此 DAAppendFile 會拒絕就地寫入並回傳 2,而不是嘗試增量更新。這不是可以透過說服控制代碼改變的策略;它在建構函式中,甚至檔案解析之前就已經設定好了。對證據工作來說,真正重要的是控制代碼關閉後來源位元組保持逐位元組相同,回歸套件正是在 AES-128 修訂版本 4 和 AES-256 修訂版本 6 的夾具上斷言這一點。串流入口的寫入行為相同,並且多一條規則:DAOpenFromStreamWithEncryptionKey 從不取得所有權,因此 DACloseFile 會讓你的 TStream 繼續存活,需要由你自己釋放

Source:= TMemoryStream.Create;
Try
  Source.LoadFromFile(ArchivePath);
  Source.Position:= 0;
  FileHandle:= Lib.DAOpenFromStreamWithEncryptionKey(Source, KeyHex);
  If FileHandle<> 0 Then
  Try
    // 2 = 唯讀控制代碼:匯出到其他位置,絕不向證據追加內容
    Assert(Lib.DAAppendFile(FileHandle)= 2);
    Harvest(Lib, FileHandle);
  Finally
    Lib.DACloseFile(FileHandle);
  End;
Finally
  Source.Free;   // 控制代碼從未擁有這個串流
End;

金鑰衛生,以及 DLL 匯出哪些內容

關閉鏈時會覆寫檔案金鑰、密碼快取和推導出的物件金鑰;無論開啟成功還是失敗,解碼後的金鑰都會在入口點自身的 Finally 區塊中清除。這裡有個曾經耗費大量除錯時間的細節:任何必須繼續存在的副本都透過 SetLengthMove 明確複製,而不是直接賦值。在 Delphi 中,把一個 AnsiString 賦給另一個時,兩個名稱會在寫入時複製機制下共用一個緩衝區,因此清除呼叫方一側會把加密處理器仍在使用的金鑰一起歸零,文件就會解密成垃圾,而堆疊追蹤不會解釋原因。只有基於檔案的入口以寬字元和 ANSI 形式跨過 DLL 邊界,同時匯出驗證狀態存取器;TStream 變體維持 Delphi 專用,因為它依賴 Delphi 物件生命週期和參照語意,而這些概念無法在扁平的 C ABI 中誠實表示。如果你的復原工具是 DLL 用戶端,就應該在相同的控制措施下暫存到暫存檔並刪除暫存檔

把十六進位金鑰當作憑據材料處理,採用對待文件密碼的處理規則,並把驗證狀態寫入保管鏈產生的日誌,這樣後來閱讀記錄的人才能區分已驗證擷取和未經證明的擷取。如果你正在為取證、批次歸檔或 DRM 移轉評估 Delphi PDF 元件,原始金鑰入口、唯讀保證和加密稽核介面都屬於同一個程式庫,完整功能清單可見 PDF Library for Delphi 產品頁