技術文章

加密 PDF 裡以明文保留的簽章值

在別處簽章的 AES 加密合約載入後,簽章值解出來是一串隨機位元組;換成 AES-GCM,則是簽章字典根本解不開。在 trailer 的 /ID 與加密字典之外,ISO 32000-2 §7.6.2 只豁免簽章的 /Contents,PDFlibPas 讀寫都讓它保持明文,而 /Reason 與 /M 照樣加密

ISO 32000-2 為加密規則加上的豁免

ISO 32000-2 §7.6.2 規定加密涵蓋檔案裡的每一個字串與串流,例外有四:

  • trailer /ID 條目的值
  • /Encrypt 字典內的字串
  • 串流內部的字串——串流是整體加密的
  • 簽章字典裡作為 /Contents 值的十六進位字串

前三項是老規則。ISO 32000-1 §7.6.1 只列了那三項,所以按 2008 年文本寫的解密器,沒有任何指示要跳過簽章字典裡的東西。簽章工具反正都把值寫成明文,原因是機械性的:簽章器先保留一個十六進位佔位空間、寫出整個檔案、對除佔位空間外的所有內容做雜湊、組出 CMS,再把它的十六進位補進缺口。那份摘要涵蓋的就是檔案躺在磁碟上的位元組,密文也在內。把簽章值加密什麼也保護不到,還會逼得修補步驟得知道檔案金鑰,並把 IV 加填充塞進一個在雜湊出現之前就定好大小的佔位空間。字典其餘部分照一般規則處理:

簽章字典條目PDF 類型是否加密儲存?
/Contents十六進位字串否,ISO 32000-2 §7.6.2 豁免
/Reason、/Location、/ContactInfo、/Name文字字串是
/M日期字串是
/ByteRange整數陣列否,整數從不加密
/Type、/Filter、/SubFilter名稱否,名稱從不加密

漏掉這條豁免的閱讀器,會拿密碼器去跑一段明文 DER。結果取決於演算法。RC4 是串流密碼,輸出長度不變、內容變噪聲;CMS 解析器看到的第一個位元組不再是 0x30,回報簽章格式錯誤。AES-CBC 把前 16 位元組當初始向量、其餘當密文,下場是填充錯誤,或一段更短的亂碼。由 ISO/TS 32003 為 PDF 定義的 AES-GCM 最嚴苛:每個加密字串都自帶 nonce 與認證標籤,明文值過不了標籤檢查。解密器若把單次標籤失敗當成整個字典致命,就會連 /Reason 與 /M 一起丟掉

PDFlibPas 在 Delphi 中的加密 PDF 簽章字典:ISO 32000-2 §7.6.2 下十六進位 /Contents 保持明文,/Reason 與 /M 照樣加密,並列 RC4、AES-CBC 與 AES-GCM 的失敗模式
加密 PDF 裡簽章 /Contents 仍是可讀的 DER,/Reason 與 /M 則是密文;硬把這段 DER 解密,各種密碼器的失敗方式各不相同

怎麼把簽章的 /Contents 從其他 /Contents 裡認出來?

拿鍵名來比對是最直覺的第一步,但兩個方向都會失手。Annotation 字典也有 /Contents 條目——一個裝著審閱者留言的文字字串——這個字串必須加密;跳過它,機密檔案裡的每張便條都會在沒有密碼的情況下被讀走。改比對 /Type /Sig 則往另一個方向出錯:ISO 32000-2 Table 255 讓 /Type 在簽章字典裡是選填的,預設值為 Sig,只有 /DocTimeStamp 才強制要有,所以合規的產生端可以不寫。因此 PDFlibPas 只有在同一個字典帶有 /ByteRange 陣列,且 /Type 為 Sig 或 DocTimeStamp,或乾脆沒有 /Type 卻帶 /Filter 名稱時,才把 /Contents 字串當成簽章值。每個簽章字典都必須有 /Filter,屬於簽章欄位的每個簽章都必須有 /ByteRange;annotation 兩者皆無。程式庫在儲存加密與載入解密時跑同一套測試,所以載入後 /Contents 拿到的就是產生端寫入的位元組

PDFlibPas 在 Delphi 中的加密測試:加密或解密字串之前,靠 /ByteRange、/Type 為 Sig 或 DocTimeStamp、以及 /Filter 來區分簽章 /Contents 與 annotation /Contents
Annotation 留言照樣加密,因為 PDFlibPas 只在 /Contents 旁邊同時出現 /ByteRange 與相符的 /Type、或 /Type 缺席時出現 /Filter 名稱,才把它當成簽章值

先用 PDFlibPas 加密,再簽章

簽章放最後。簽章摘要涵蓋的是已儲存的位元組,所以簽章之後任何改寫檔案的操作都會弄壞裡面的每一個簽章。對已簽章的文件套用加密,會把每個字串與串流重新加密;解除加密也一樣把它們改寫得徹底。可行的順序是:加密、儲存,然後對加密後的檔案簽章。簽章程序會附加一個遞增更新,其中的新字串以既有檔案金鑰加密,/Contents 佔位空間則保持明文。在簽章程序裡要用擁有者密碼開檔;只有使用者密碼時,必須 ISO 32000-2 Table 22 的權限包含位元 9 才夠——那個位元明白列出了簽章欄位。Encrypt 與簽章程序在每個 PDFlibPas 版本都可用

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // 列印、填寫欄位(位元 9 涵蓋簽章欄位)、無障礙複製、高解析列印
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 revision 6:直接使用檔案金鑰,/ID 不參與
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // 不存在時以隱形方式建立
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // 佔位空間裡預留的餘裕,供日後加入的簽章時間戳使用
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // 一律回傳 0
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 選用 AES-256 revision 6,這個選擇消掉兩種失敗模式。RC4 與 AES-128 之下,Algorithm 1(ISO 32000-1 §7.6.2)會依物件號與世代號為每個物件另外導出一把金鑰;Algorithm 2(ISO 32000-1 §7.6.3.3 步驟 e)還把第一個 /ID 元件攪進檔案金鑰。簽章更新會改寫 AcroForm 字典與頁面的 /Annots 陣列,所以每個被改寫的物件都必須保有原本的世代號,更新的 trailer 也必須帶同樣的第一個 /ID 元件。世代號錯了,該物件就解不開;/ID 錯了,全部都解不開。Revision 6 直接使用檔案金鑰(ISO 32000-2 §7.6.3.3,Algorithm 1.A),取回金鑰時也不看 /ID(§7.6.4.3.3,Algorithm 2.A)。PDFlibPas 在各種強度下都讓被改寫的物件保留世代號,並按儲存的樣子從 /ID 導出金鑰——即使畸形檔案帶的是 /ID 7 0 R 而非 ISO 32000-2 Table 15 要求的直接陣列;空的 /ID [] 則過不了密碼檢查。密碼到金鑰這條路徑的深入追蹤,見以原始檔案金鑰開啟加密 PDF一文

文字編輯器就夠您看結果。簽章字典在加入它的那個更新裡不經壓縮,因為簽章器是按檔案偏移修補 /ByteRange 與 /Contents 的。搜尋 /ByteRange 就能直接跳到它。/Contents 的十六進位通常以 3082 開頭——那是 CMS SignedData SEQUENCE 的 DER 表頭——並以一長串零收尾,那是佔位空間比簽章大的部分。/Reason 是密文,即使文字不變,每次簽章跑出來的密文也不一樣,因為 AES 為每個字串抽新的 IV。用新版 qpdf 的預期結果:qpdf --password=owner-secret --show-object=14 signed.pdf 會印出解密後的 /Reason,旁邊是原封不動的 /Contents。做這項檢查請用 strength 4 的檔案。原始檔案裡的預期形狀如下,偏移量與位元組會有出入:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

修補時間戳進去時,哪些簽章會壞?

時間戳之後加上的每個簽章都會壞。PAdES 簽章時間戳是 RFC 3161 權杖,以簽署者的未簽名屬性存放(RFC 5652 §5.3 的 unsignedAttrs),所以它就在 /Contents 的 CMS 裡面。加入時間戳意味著就地改寫那段十六進位。在加密檔案裡,這個改寫之所以可行,正是因為 /Contents 是明文:不必重新加密、不產生新物件——權杖只需要塞得進簽章當時保留的空間。擁有它的簽章之所以活下來,是因為它的 /ByteRange 跳過那些位元組。更晚的簽章沒有這種豁免。ISO 32000-2 §12.8.1 規定每個簽章的位元組範圍從 %PDF- 表頭一路跑到加入它的那個遞增更新結尾。因此第二個簽章(或文件時間戳)會把第一個簽章的 /Contents 連同其他一切一起拿去雜湊。這個缺口只屬於一個簽章。等第二個簽署者簽完再修補它,第二個簽章的摘要就對不上了。權杖請求本身屬於從 Delphi 做 PAdES 簽章與驗證的範圍;這一節只談哪些位元組會動

PDFlibPas 在動手雜湊之前先檢查重疊。簽章時間戳程序會讀取文件裡每個其他簽章的 /ByteRange,把每一對區間與目標的 /Contents 缺口比對。有任何重疊,準備作業就以程序結果 17 停止,不寫出任何輸出檔。這個程序屬於 Enterprise 版,以 PDFlibEnterprise 單元裡的 TPDFlibEnterprise 實體化;其他版本保留這些方法,但它們回傳 0 並把 LastErrorCode 設為 900。日誌裡看到 17 這個數字要留心:出自 GetPAdESSignatureTimeStampProcessResult,意思是更晚的簽章蓋住了這一個;出自 GetSignProcessResult,意思是認證層級或欄位鎖禁止簽章。兩者要分開解讀。下面的輔助程式用到上一段程式碼的 SignField 與 OwnerPwd

PDFlibPas 在 Delphi 中的 PAdES 簽章時間戳順序:更晚簽章的 /ByteRange 蓋住較早的 /Contents 缺口時,GetPAdESSignatureTimeStampProcessResult 回傳 17,且不寫出任何檔案
Approver 簽完之後,它的位元組範圍就蓋住 Author 的 /Contents 缺口;PDFlibPas 以程序結果 17 拒絕遲來的 Author 時間戳,而不是弄壞較晚的簽章
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // 值已雜湊,TSA 交換失敗:見 GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // 預期:17。Approver 的位元組範圍蓋住了 step2.pdf 裡 Author 的
    // /Contents,所以程序拒絕,restamped.pdf 不會被寫出
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

在下一個簽署者碰檔案之前,先把每個簽章的時間戳蓋好。每位簽署者多一趟 TSA 往返,換來每個簽章自己的可信時間。拿到手時已有好幾個簽章的檔案是另一回事:較早的那些已經無法補簽章時間戳。改附加文件時間戳——在新修訂版裡放一個 /Type /DocTimeStamp、帶 /SubFilter /ETSI.RFC3161 的簽章,其位元組範圍涵蓋所有較早的簽章,證明它們在那個時間點存在。ApplyPAdESLongTermArchiveValidation 會在附上驗證材料之後加一個。簽章時就透過第一段程式碼裡的 SetSignProcessReserveContentsBytes 為簽章時間戳預留空間。內嵌 TSA 憑證鏈的權杖輕易就到數 KB,佔位空間少了這份餘裕,日後的補強就會以結果 13 失敗

解密已簽章的檔案,以及驗證器雜湊的對象

加密檔案裡被簽章涵蓋的範圍是密文。驗證器比較儲存密文的摘要與 CMS 內的摘要:/ByteRange 是整數、/Contents 是明文,完整性檢查不需要金鑰——需要金鑰的只有欄位名稱與 /Reason。同一個事實也解釋了為什麼把已簽章的檔案解密後存成明文,會讓裡面每個簽章全部失效,儘管可見的像素一個都沒變。請把加密且已簽章的原始檔當作存檔正本。交叉參照串流同樣從不加密(ISO 32000-2 §7.5.8.2),所以以 xref stream 寫出的更新,在導出任何金鑰之前就保有可讀的偏移量。這種布局的說明見Delphi 的物件串流與交叉參照串流。被簽章的範圍大到無法一次讀進記憶體比對時,PDFlibPas 把完整性檢查回報為未知而非無效,因為從未比對過的位元組,並不能證明它們有差異

加密檔案的簽章值在簽章之後仍可改寫,直到另一個簽章或文件時間戳蓋過它。把時間戳步驟放進這個窗口內,第一段程式碼的先加密後簽章順序就能原樣帶到PDF Library for Delphi 的多重簽署者檔案上