對已帶有 AES-256 加密的發票 PDF,若要求適用於 Delphi 與 C++Builder 的 PDFium Component(PDFiumPas)透過增量更新而非完整重寫來加入 PDF/A 歸檔標記,或以 PAdES 簽署,程式庫不能直接修補加密位元組:六個合規標記注入器會偵測現有的 /Encrypt 項目,並逐位元組原樣傳遞來源內容,而 PAdES 簽署器會擲出例外,不會產生任何驗證器都無法接受的簽章
這與稽核並非由你建立、可能含有隱藏風險的 PDF 是不同問題,後者屬於獨立的唯讀作業。本文討論的是同一個信任邊界的寫入面:當程式碼事後嘗試向一個位元組已被他人密碼鎖定的檔案加入內容時,它究竟可以對該檔案做什麼
更新加密 PDF 時,ISO 32000-1 要求什麼
ISO 32000-1 §7.5.6 要求增量更新的 trailer 重複前一個 trailer 中的每個項目,但 /Prev 除外,而表 15 將 /Encrypt 列為 trailer 可攜帶的項目之一。若從新 trailer 移除它,符合規範的讀取器沒有理由質疑這項省略:最新的 trailer 具有權威性,因此找不到 /Encrypt 的讀取器會判定整個檔案未加密,並嘗試將較舊且仍然加密的本文解析為純位元組。若保留新 trailer 中的 /Encrypt,卻以明文寫入更新內容自己的物件,失敗只會延後一步:讀取器正確偵測到加密後,會以檔案的密碼處理它接觸到的每個物件,包括原本從未加密的新物件,於是在解密碰觸內容前原本完全可讀的資料上得到雜訊。兩種錯誤都會產生位元組層級看似正常且格式完整的增量更新,直到符合規範的讀取器開啟檔案為止
六個標記注入器與 v2.14.2 加密閘門
PDFiumPas 提供六個位元組層級的標記注入器,分別對應六種 ISO PDF 子集:PDF/A(ISO 19005)、PDF/X(ISO 15930)、PDF/UA(ISO 14289-1)、PDF/E-1(ISO 24517-1)、PDF/R-1(ISO 23504-1)和 PDF/VT-1(ISO 16612-2)。每個注入器都會處理 PDFium 已寫入的位元組 FPDF_SaveAsCopy,再疊加新的 XMP 中繼資料流、catalog 字典編輯,以及列印導向子集所需的 OutputIntent 和 ICC 設定檔。從 v2.14.2 開始,InjectPdfAMarkers、InjectPdfXMarkers、InjectPdfUaMarkers、InjectPdfEMarkers、InjectPdfRMarkers 和 InjectPdfVTMarkers 都會先讀取來源 trailer;如果已有 /Encrypt 項目,就將來源內容原樣複製到目標流後返回,不加入任何標記
var
Src, Dst: TFileStream;
Opts: TPdfXSaveOptions;
begin
Src := TFileStream.Create('signed-encrypted-proof.pdf', fmOpenRead);
Dst := TFileStream.Create('pdfx-attempt.pdf', fmCreate);
try
Opts.Conformance := pxc4;
InjectPdfXMarkers(Src, Dst, Opts);
// pdfx-attempt.pdf is byte-identical to the source: still encrypted,
// no /GTS_PDFXVersion, no OutputIntent. Nothing was written, and
// nothing was corrupted either
finally
Dst.Free;
Src.Free;
end;
end;
允許加密不等於可以安全注入
PDF/E-1 和 PDF/R-1 都在規範層面明確允許宿主文件加密,但只要查看磁碟上實際必須完成的工作,就會發現這像是一項豁免,位元組層級後處理器仍無法安全地向加密容器加入明文物件。ISO 24517-1 §6.3 允許 PDF/E-1 使用加密,ISO 23504-1 §6.2.3 則允許 PDF/R-1 使用加密,前提是檔案標頭宣告 %PDF-2.0。這兩項條文都沒有說明位元組層級後處理器是否能安全地向加密容器加入明文物件,而它確實不能,原因正是適用於所有子集的 §7.5.6。PDFiumPas 自己的這兩個設定檔合規驗證器 ValidatePdfECompliance 和 ValidatePdfRCompliance 會刻意記錄 /Encrypt 的存在而不將其標記為缺陷,對從不寫入位元組的唯讀驗證器而言這是正確行為;但也很容易略過這個模式,誤以為相應的注入器不需要獨立防護,然而真正必須拒絕的是這對函式中會寫入內容的注入器
SaveAsPdfX 會靜默解密文件嗎
如果使用公開的便利方法而不是直接呼叫注入器,答案是會。TPdf.SaveAsPdfA、SaveAsPdfX、SaveAsPdfUa、SaveAsPdfE、SaveAsPdfR 和 SaveAsPdfVT 都會先透過 SaveAs(Tmp, saRemoveSecurity) 將目前文件轉譯到暫存流,再把那些位元組交給相符的注入器。saRemoveSecurity 對應 PDFium 自己的 FPDF_REMOVE_SECURITY 旗標,因此注入器收到的暫存副本一開始就未加密,注入器的 /Encrypt 防護也沒有觸發理由。輸出會帶有 PDF/A、PDF/X、PDF/UA、PDF/E-1、PDF/R-1 或 PDF/VT-1 標記,但不再受開啟來源所用的密碼保護
這項取捨直到下游有人不使用密碼開啟那份受保護的歸檔副本,並發現它可以直接開啟時,才會顯得不明顯。修正方式不是改用另一個方法呼叫;PDFiumPas 沒有可與 saRemoveSecurity 配對的 saAddSecurity 對應項,因為底層 PDFium 引擎從未被設計成寫入新的加密,只能移除加密。如果同一個檔案需要同時具備這兩項特性,就必須由你負責將加密作為獨立步驟,在合規標記之後套用,而不是合併到同一個 SaveAsPdfA 呼叫中
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'open-secret'; // needed to open the source at all
Pdf.FileName := 'signed-encrypted-invoice.pdf';
Pdf.LoadDocument;
Pdf.SaveAsPdfA('invoice-pdfa.pdf', pac2b);
// invoice-pdfa.pdf now declares PDF/A-2b, but SaveAs(saRemoveSecurity)
// ran first inside SaveAsPdfA: the output opens without a password
finally
Pdf.Free;
end;
end;
使用 PAdES 簽署加密 PDF 時會發生什麼
PDFiumPas 會直接拒絕,而不會像標記注入器那樣靜默放棄要求。TPdf.SignPades 和 SignPadesToStream 都會經過 SignPadesBytes,在解析來源 trailer 後先檢查 /Encrypt。如果項目存在,就會擲出 EPadesCrypto,並攜帶訊息「SignPadesBytes: the source document is encrypted; remove encryption before signing」,不會繼續執行。InjectPadesDssMarkers 也會執行相同檢查,並使用訊息「InjectPadesDssMarkers: the source document is encrypted; remove encryption before embedding DSS validation material」
簽章不能像標記注入那樣悄悄失敗,因為只檢查布林結果的呼叫程式會把被略過的簽章誤認為成功。EPadesCrypto 繼承自普通的 Exception 類別,因此捕捉它屬於正常的例外處理
var
Pdf: TPdf;
begin
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'open-secret';
Pdf.FileName := 'encrypted-contract.pdf';
Pdf.LoadDocument;
try
Pdf.SignPades('encrypted-contract-signed.pdf', 'A1B2C3D4E5F6...');
except
on E: EPadesCrypto do
// E.Message: 'SignPadesBytes: the source document is encrypted;
// remove encryption before signing'
raise Exception.Create('Decrypt the contract before signing: '+ E.Message);
end;
finally
Pdf.Free;
end;
end;
依序套用合規標記、簽章與加密
實際的修正方式在於順序,而不是改用另一個程式庫。先套用 PDF/A、PDF/X、PDF/UA、PDF/E-1、PDF/R-1 或 PDF/VT-1 標記,接著加入任何 PAdES 簽章,最後才執行流程中真正負責加密的步驟,無論那是專用 PDF 寫入器、簽署設備或你自己的 AES 實作。PDFiumPas 的增量更新層自然適合置於流程中間,將小而明確的物件附加到其他部分都已完成的檔案上,而加密之所以屬於最後一步,正是因為它是整條鏈中 PDFiumPas 自己無法執行或反轉的唯一操作
這些限制不會改變 PDFiumPas 讀取每個增量更新所依賴的 trailer 和交叉參照資料的方式;一旦 xref 流出現,這條路徑本身就有另一層細微性。驗證 PDF 的物件與 xref 流說明相同的 trailer 讀取路徑如何處理 PDF 1.5+ 的壓縮結構,而當文件準備好進行強度高於合規標記的操作時,在 Delphi 中使用 PAdES B-B 簽章簽署 PDF會從本文結束的位置接手 SignPades
本文所述的標記注入器和 SignPades 方法屬於適用於 Delphi 與 C++Builder 的 PDFium Component,並與 PDFium 原生提供的轉譯和唯讀檢查能力一同提供