您收到了一份已簽署的 PDF,且需要在檢視器中顯示是誰簽署的、何時簽署的、簽章是否覆蓋了整個檔案,以及它在長期合規性方面達到了何種程度。適用於 Delphi 和 Lazarus 的 PDFium 元件 透過唯讀呼叫回答了這四個問題:FPDF_GetSignatureCount 和 FPDFSignatureObj_* 系列公開了簽章字典,且 TPdf.ValidatePades 分類了 PAdES 基準等級。這是關於使用 PDFium 處理 PDF 簽章的三篇文章中的第一篇;後續兩篇將介紹建立 B-B 簽章和加入長期時間戳記。不過,有一點界限需要事先說明:這裡的一切都是檢查,讀取簽章所聲明的內容與驗證其密碼學或決定是否信任簽署者是完全不同的工作
為什麼 PDF 簽章字典不僅僅是一大塊位元組資料
PDF 簽章是一個字典,而非不透明的附件,其兩個最重要的項目會告訴您檔案的哪些部分實際上受到了保護。ISO 32000-1 §12.8 使用 /ByteRange 和 /Contents 項目定義了簽章字典。/Contents 保存了十六進位編碼的 CMS SignedData 結構(RFC 5652),即承載簽署者憑證、已簽署屬性和簽章值本身的密碼學封套。/ByteRange 是開發人員容易低估的部分:它是一個由兩個位移量長度範圍組成的陣列,兩者共同覆蓋了除 /Contents 十六進位字串之外的整個檔案。該間隙正是簽章位元組所在的位置,而兩側的兩個範圍正是該簽章所承諾保護的範圍
ByteRange 的設計使得增量儲存(incremental save)可被稽核。因為簽署者無法對尚不存在的簽章位元組進行雜湊,所以檔案會在 /Contents 預留位置周圍進行分割,而其他所有內容都會被雜湊至簽章中。ByteRange 未達檔案末尾的簽章是一個警告訊號:在覆蓋範圍之後透過後續的增量更新附加的內容,即使改變了讀者看到的內容,也不會破壞簽章。因此,認真的檢查人員首先檢查的不是誰簽署了檔案,而是簽章是否覆蓋了它似乎代言的位元組範圍
使用 PDFium 的唯讀 API 讀取簽章字典
PDFium 元件透過兩個唯讀成員展現簽章字典:SignatureCount 和 Signature[Index] 記錄。在底層,它們呼叫 FPDF_GetSignatureCount、FPDF_GetSignatureObject,以及針對 /SubFilter、/ByteRange、/Contents、/Reason 和簽署時間的 FPDFSignatureObj_* 存取函數。這裡的關鍵詞是「唯讀」:PDFium 可以列舉和讀取簽章,但沒有 API 來建立或寫入簽章,這就是為什麼本系列中簽署端的功能是由該函式庫本身(而非 PDFium)實作的原因
var
Pdf: TPdf;
i: Integer;
Sig: TPdfSignature;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract-signed.pdf';
Pdf.Active := True;
for i := 0 to Pdf.SignatureCount - 1 do
begin
Sig := Pdf.Signature[i];
Writeln('SubFilter : ', Sig.Encoding); // ETSI.CAdES.detached、adbe.pkcs7.detached 等
Writeln('Signed at : ', Sig.Time); // 簽署者日期字串,例如 D:20260708120000+02'00'
Writeln('Reason : ', Sig.Reason);
Writeln('CMS length: ', Length(Sig.Content)); // 取自 /Contents 的原始 DER SignedData
Writeln('DocMDP : ', Sig.Permission); // 0 = 無認證, 1..3 = MDP 等級
end;
finally
Pdf.Free;
end;
end;
每個 TPdfSignature 記錄直接對應到該字典。Encoding 是 /SubFilter(最具診斷價值的單一欄位),因為它命名了簽章處理常式,並能立即將現代的 ETSI.CAdES.detached 簽章與舊版或禁止的簽章區分開來。Time 是作者宣告的簽署時間(以 PDF 日期字串表示),這是簽署者的聲明,而非可信時間。Content 是原始的 CMS SignedData,且 Permission 公開了 DocMDP 憑證等級(0 代表普通的核准簽章,1 到 3 代表鎖定後續變更的認證簽章)。該記錄唯一沒有顯現的欄位是解析後的 ByteRange,而這種省略是刻意的,因為 ValidatePades 會為您完成 ByteRange 覆蓋範圍的計算,而不需要您手動重做
PAdES B-B、B-T、B-LT 和 B-LTA 有何不同?
四個 PAdES 基準等級形成了一個階梯,從最低限度有效的簽章,到專為保存數十年封存而設計的簽章,且每個等級都嚴格包含其下的等級。ETSI EN 319 142-1 將它們定義為 B-B、B-T、B-LT 和 B-LTA。B-B(Basic,基本)是簽章加上必要的已簽署屬性,僅此而已。B-T(Timestamp,時間戳記)在簽章之上加入一個可信的 RFC 3161 時間戳記,因此簽署時刻是由時間戳記授權機構(TSA)證明,而非由簽署者的時鐘宣告。B-LT(Long-Term,長期)在檔案內部嵌入驗證資料——憑證鏈以及選用的 OCSP 或 CRL 回應,因此在多年後發證機構的基礎建設不復存在時,簽章仍可被驗證。B-LTA(Long-Term with Archive timestamp,長期封存時間戳記)使用文件時間戳記包裝該資料,保護長期資料本身,並在底層密碼學老化之前為您提供重新進行時間戳記的依據
實際的解讀與時間跨度有關。B-B 簽章回答了「是否有人簽署了此文件」。B-T 回答了「以及何時簽署的,且是可證明的」。B-LT 回答了「以及在憑證過期後我是否仍能檢查它」。B-LTA 回答了「以及該檢查在二十年後是否依然有效」。法規設定會選擇一個階梯:許多電子發票和 eIDAS 情境要求至少達到 B-T 等級,而封存要求則傾向於 B-LT 或 B-LTA。在接受或拒絕文件之前,確切瞭解文件達到了哪個階梯,正是檢查步驟的全部意義所在
使用 TPdf.ValidatePades 偵測基準等級
PDFium 元件將整個等級問題簡化為單次呼叫。TPdf.ValidatePades 傳回 TPadesValidationResult 記錄,其 Level 欄位是 TPadesLevel 列舉(plNone、plUnknown、plB_B、plB_T、plB_LT 或 plB_LTA),同時傳回一組問題、簽章計數和文件時間戳記計數。該等級是單調推導出的:驗證器首先確定 B-B,如果存在簽章時間戳記或文件時間戳記,則提升為 B-T;如果目錄中攜帶了包含憑證的 /DSS 且包含 /Extensions /ESIC 等級 1 標記,則提升為 B-LT;如果文件時間戳記和 ESIC 等級 2 標記同時存在,則提升為 B-LTA。兩個協助工具使結果具備可操作性:僅當等級至少達到 B-B 且問題集合為空時,IsCompliant 才為 True,而 IsCompliantAt 允許您斷言原則下限(例如 plB_T)
var
Pdf: TPdf;
R: TPadesValidationResult;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract-signed.pdf';
Pdf.Active := True;
R := Pdf.ValidatePades;
case R.Level of
plNone: Writeln('No PAdES signature present');
plUnknown: Writeln('Signature present but level undeterminable');
plB_B: Writeln('PAdES B-B (基本)');
plB_T: Writeln('PAdES B-T (受信任時間戳記)');
plB_LT: Writeln('PAdES B-LT (嵌入了長期資料)');
plB_LTA: Writeln('PAdES B-LTA (封存時間戳記)');
end;
Writeln('Signatures : ', R.SignatureCount);
Writeln('DocTimeStamps: ', R.DocTimeStampCount);
if R.IsCompliantAt(plB_T) then
Writeln('符合 B-T 原則下限')
else
Writeln('低於要求的 B-T 等級');
finally
Pdf.Free;
end;
end;
為什麼 adbe.pkcs7.sha1 是被禁止的 SubFilter?
因為 SHA-1 已被破解,且 adbe.pkcs7.sha1 處理常式將其內置其中。該 SubFilter 在將文件包裝至 PKCS#7 之前會使用 SHA-1 對其進行預先雜湊,而 SHA-1 多年來一直容易受到碰撞攻擊,因此 EN 319 142-1 第 6.3 條完全禁止將其用於基準簽章。當 ValidatePades 遇到 adbe.pkcs7.sha1 或 adbe.x509.rsa_sha1 時會引發 ppeiForbiddenSubFilter,且在 CMS 本身使用 MD5 或 SHA-1 作為訊息摘要時會引發 ppeiBadDigestAlgorithm(第 6.2.1 條)。這是兩個不同的檢查,在兩個不同的層級捕獲了同一種類型的漏洞
問題集共有 26 個成員,您最常遇到的問題主要集中在結構和覆蓋範圍周圍。ppeiByteRangeNotCoveringFile 是前面介紹的覆蓋範圍檢查。當簽章字典包含 /Cert 項目時會觸發 ppeiForbiddenCertKey,這在 PAdES 中是被禁止的,因為憑證鏈必須存在於 CMS SignedData.certificates 中。ppeiMissingSigningCertificate、ppeiMissingContentType 和 ppeiMissingMessageDigest 標記了缺失的必要已簽署屬性,且 ppeiDetachedContentViolation 捕獲了錯誤嵌入已簽署內容而非將其分離的簽章。列舉此集合可將單純的拒絕轉換為您可以記錄的診斷資訊
var
R: TPadesValidationResult;
Issue: TPadesValidationIssue;
begin
R := Pdf.ValidatePades;
if R.Issues <> [] then
for Issue := Low(TPadesValidationIssue) to High(TPadesValidationIssue) do
if Issue in R.Issues then
Writeln('Issue: ',
GetEnumName(TypeInfo(TPadesValidationIssue), Ord(Issue)));
end;
ValidatePades 不檢查的內容
ValidatePades 驗證的是結構而非信任,將兩者混淆是危險的錯誤。plB_LTA 的結果表示文件包含一個格式正確的 B-LTA 簽章,所有規定的屬性、資料和時間戳記都處於正確的位置——這並不意味著該簽章在密碼學上是有效的、憑證鏈能連結到您信任的根憑證,或者憑證鏈中的憑證未被撤銷。驗證器刻意不執行密碼學驗證:它不重新計算 ByteRange 上的簽章、不建立或評估信任鏈,也不檢查 OCSP 或 CRL 撤銷狀態。這種分離是刻意且有用的,因為結構檢查速度極快、完全是確定性的,且不需要金鑰、不需要網路和平台密碼學,因此 ValidatePades 作為純 Pascal 程式碼在檔案位元組上執行,在 Windows、Linux 和 macOS 上的運作方式完全相同。相比之下,信任鏈驗證與原則密不可分——您信任哪些根憑證、如何獲取撤銷狀態、時間戳記容許度有多嚴格——它屬於依賴平台憑證存放區的後續階段。因此,請將通過的 ValidatePades 視為簽章格式正確的必要關卡,然後在依賴它之前,將結構完好的簽章提交給真實的密碼學驗證
該結構性檢查是合適的起步點,它自然地與 使用 PDFium 元件稽核 PDF 安全風險 中更廣泛的唯讀檢查,以及諸如 驗證列印就緒的 PDF/X 文件 等格式一致性工作相結合。一旦您能夠讀取和分類簽章,下一步就是產生簽章:本系列的第二篇文章介紹了使用 PAdES B-B 簽署 PDF,而第三篇則將其擴展至受信任時間戳記以及長期 B-LT 和 B-LTA 簽章。此處介紹的唯讀簽章檢查與 ValidatePades 分類器隨附於適用於 Delphi、C++Builder 和 Lazarus 的 PDFium 元件 中