一個將合規性驗證與數位簽章串聯起來的工作台,必須協調四個步驟(依此順序),並確保它們全程都綁定到同一組位元組 (bytes)。它會執行 PDF/A 或 PDF/UA 預檢。它會套用任何調查結果所要求的修正,並儲存一個修正後的版本。它會簽署那個確切的版本。然後它會將簽署後的檔案讀回來,並確認簽章真的涵蓋了它。這個順序不是裝飾性的。跳過讀回來的步驟,您就是在信任您自己的寫入路徑;讓預檢對著錯誤的版本執行,您的合規性報告描述的就是一個您從未發布的檔案
大多數自製管線容易出錯的地方,在於驗證與簽章之間的接縫。將它們作為兩個獨立的工具執行,並在中間進行修復 (remediation) 的過程,至少會產生三個不同版本的檔案,每個版本都有自己的位元組。您交給稽核員的預檢報告描述的是其中一個。簽章凍結了另一個。檔案中沒有任何內容聲明它們是同一個版本,而且通常它們也不是。PDFlibPas(適用於 Delphi 和 C++Builder 的 losLab PDF 開發人員函式庫)將預檢與 PAdES 簽章隱藏在單一個外觀 (facade) 類別之後,所以整個序列可以存在於單一處理程序 (process) 中,永遠不會失去對其正在處理的位元組的追蹤。下面每個呼叫都存在於今天的函式庫中,旁邊註記的每個陷阱也是如此
一份文件的三個版本,以及差距是如何拉開的
數一數儲存的次數。原始檔案從上游到達。修復過程載入它,開啟合規模式,並寫入一個修正後的版本。簽章過程以增量更新 (incremental update) 的方式附加簽章,這是第三次寫入。三次儲存、三種位元組佈局,而一份預檢報告如果沒有指明它涵蓋的是哪一個,就毫無意義。在每一次預檢與每一次簽章旁邊記錄下檔案的 SHA-256,這是一種廉價的錨點,讓您可以證明您所驗證的版本正是您簽署的版本
函式庫的一項行為進一步收緊了這種紀律。透過 SetPDFAMode 或 SetPDFUAMode 要求的合規性修正,並不會在您呼叫它們時生效。它們是在儲存期間套用的。像強制標註列印旗標或指派 PDF/UA 索引標籤順序之類的自動修復會落在輸出檔案中,而不會在其他任何地方,因此,針對您剛剛在記憶體中「修正」的文件所執行的檢查,無法告訴您任何關於即將送往簽署者的位元組的資訊。先儲存,然後對儲存的檔案進行預檢。記憶體中的狀態只是草稿;只有磁碟上的檔案才是真實的
從磁碟預檢,以及代表兩件事的零
扁平的 (flat) 預檢進入點是 CheckFileCompliance(FileName, Password, ComplianceTest, Options)。Test 1 選擇 PDF/A (ISO 19005),test 2 選擇 PDF/UA (ISO 14289)。它透過函式庫的串流讀取器開啟檔案,所以不需要先 LoadFromFile,它會傳回一個字串清單控制代碼 (handle),每個條目帶有一個發現結果:
var
PDF: TPDFlib;
ListID, I: Integer;
begin
PDF := TPDFlib.Create;
try
ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0); // 1 = PDF/A
if ListID = 0 then
begin
if PDF.LastErrorCode <> 0 then
raise Exception.Create('Preflight could not read the file')
else
Writeln('No PDF/A findings');
end
else
begin
for I := 0 to PDF.GetStringListCount(ListID) - 1 do
Writeln(PDF.GetStringListItem(ListID, I));
PDF.ReleaseStringList(ListID);
end;
finally
PDF.Free;
end;
end;
陷阱就在傳回值中,而且它是那種會通過每一個快樂路徑 (happy-path) 測試的陷阱。零表示「沒有發現問題」。零也表示「檔案無法開啟」,因為只要結果清單是空的(包含讀取失敗),實作就會傳回 0。一個將 0 視為綠燈的工作台,將會愉快地核准一個被其他處理程序鎖定的檔案。將該呼叫與 LastErrorCode 搭配使用(如上所示),才能區分這兩種情況。檢查器也會以拒絕寫入 (deny-write) 的共用模式開啟檔案,因此如果您的修復步驟仍然持有一個寫入器控制代碼,預檢就會因為一個與合規性無關的理由而失敗,完全是因為您忘記釋放某個串流
當需要由人而不是管線來閱讀發現結果時,CreatePreflightReport 會將它們渲染成易讀的報告。ComparePreflightReports 會對比兩次執行結果 (diffs),這是一種整理乾淨的方法,用來顯示修復作業已清除了原始的問題,而沒有悄悄引入新問題
使用 SignProcess 簽署檢查過的版本
一旦儲存的版本通過預檢且其雜湊已記錄在案,請簽署那個確切的檔案,而不是其他任何檔案。SignProcess API 讀起來像是一個建立器 (builder)。開啟一個程序控制代碼、逐行設定它、提交 (commit),然後讀回結果碼
ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
raise Exception.Create('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached'); // PAdES baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2); // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192); // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
該序列中有兩行帶有比看起來更重要的份量。帶有 ETSI.CAdES.detached 的 SetSignProcessCustomSubFilter 會挑選在 ETSI EN 319 142-1 中設定設定檔 (profiled) 的 PAdES 簽章,而不是舊有的 adbe.pkcs7.detached 家族,這是歐洲驗證器接受的簽章與它會標記的簽章之間的差異。SetSignProcessReserveContentsBytes 為 /Contents 佔位符加上填補 (pad),而您在這裡選擇的大小是對未來的決定:如果稍後有任何簽章時間戳記要跟進,擴大的 CMS 就必須適合您現在保留的空間,因為佔位符稍後無法在不重新簽署整個檔案的情況下增加。寬裕地保留,您只會浪費幾 KB。保留得太緊,時間戳記步驟在幾個月後就會因為溢位而失敗,而您會很難將這與這一行程式碼連結起來
GetSignProcessResult 的回應是一個代碼,而不是布林值,而這些代碼是值得保留的。1 是成功。4 是錯誤的 PDF 密碼,7 是錯誤的憑證密碼,9 是不包含私密金鑰 (private key) 的 PFX,11 是套用簽章時發生失敗。將這些崩塌為一個 true/false,您就扔掉了能將「密碼錯誤」的支援案例與「沒有私密部分的金鑰」區分開來的唯一資訊。請記錄下這個整數
讀回:稽核您剛產生的檔案
任何工作台都不應該信任寫入它即將認證之檔案的路徑。稽核類別 TPDFlibSignDoc 會重新開啟簽署後的輸出,並直接從磁碟讀取簽章字典 (dictionary) 條目:
var
Doc: TPDFlibSignDoc;
Names: TStringList;
FS: TFileStream;
I: Integer;
SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
// Capture the size before Open: the audit object holds a share lock on the file
FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
SourceSize := FS.Size;
FS.Free;
Doc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
Doc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
if Doc.GetSignatureValueObjNum(Names[I]) > 0 then // > 0 means the field is signed
begin
RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
GapStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
TailStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
TailLen := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
Writeln(Names[I], ': signature covers the file to EOF')
else
Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
end;
Doc.Close;
finally
Names.Free;
Doc.Free;
end;
end;
ValueKey 引數對應到字典條目。Key 0 傳回來自 /Contents 的原始 CMS,keys 2 和 3 傳回 /Filter 和 /SubFilter 名稱,而 11 到 14 傳回四個 ByteRange 數字。文字值則改由 GetSignatureTextValueByName 傳回:key 0 是聲稱的簽署時間,而 key 5 則能區分普通的 Sig 與 DocTimeStamp,一旦一份文件同時包含這兩者,這就很重要了
該範例頂部的檔案大小擷取是承受載重的 (load-bearing),而不是內務處理 (housekeeping)。TPDFlibSignDoc.Open 在其整個生命週期內都以限制性的共用鎖定來持有檔案,因此任何需要原始位元組的操作(對簽署範圍進行雜湊處理、重新計算 CMS 摘要)都必須在呼叫 Open 之前讀取檔案。函式庫本身的 SigningWorkbench 示範正是因為這個原因而先將整個檔案讀入記憶體,而忽略該順序的工作台會間歇性地失敗,就看哪台機器剛好在競爭 (race) 中輸了
證明涵蓋範圍的 ByteRange 算術
一個健康、單一簽章檔案的 ByteRange 形式為 [0 a b c]:涵蓋範圍從偏移量 0 開始,跳過 a 和 b 之間的十六進位 /Contents 佔位符,然後在位元組 b+c 恢復。當 b+c 等於檔案大小時,簽章涵蓋了到檔案結尾 (EOF) 的所有內容,這正是您想要的結果。當它不足時,表示有人在簽章寫入後附加了增量更新。根據 ISO 32000-1§12.8,這是完全合法的,因為後來的表單填寫、第二個簽章,以及 DSS 字典都是以這種方式到達的。這也正是一條稽核軌跡 (audit trail) 應該在簽署時記錄下來,而不是在爭議期間承受壓力重建的事實
執行此算術時,請留意整數的寬度 (width)。扁平 API 的 GetSignProcessByteRange 交回的是 32 位元的 Integer,但底層的值是 Int64,所以在超過 2 GB 的檔案上,扁平存取器會默默地截斷 (truncate)。請使用類別層級的 TPDFlibSigner.GetByteRange(它傳回 Int64),或是像上面的稽核程式碼那樣,從 GetSignatureValueByName 中解析出值
函式庫留給您的工作
有兩條邊界,在設計階段學習會比在最後衝刺階段學習來得好。扁平的 TPDFlib API 完全不包含簽章驗證包裝器。密碼學驗證位於下一層,也就是 TPDFlibSignatureVerifier 中,它的 VerifySignature 會回答有效 (valid)、無效 (invalid) 或未知 (unknown)。也沒有內建的 HTTP 用戶端可用於 RFC 3161 時間戳記授權單位 (TSA)。函式庫會計算要提交的雜湊,並在權杖 (token) 回來後重新嵌入擴充的 CMS,但到 TSA 的網路來回傳輸 (round trip) 必須由您來寫。兩者都很容易包裝,而在發布前一週才發現遺漏這兩者絕對令人不快,所以請從第一份草圖開始就把它們設計進去
關於合規性的一個問題值得清楚地解決,因為它決定了最後一道閘門設在哪裡:加上簽章會破壞 PDF/A 嗎?單憑簽章本身不會。簽章會作為增量更新到達,而 ISO 19005-2 起明確允許經過簽署的文件。陷阱在於簽章外觀 (appearance),它遵守與任何其他頁面內容相同的規則,包含內嵌字型和不能有設備相關 (device-dependent) 色彩。所以工作台中的最後一道閘門,是再執行一次預檢,這一次是針對簽署後的輸出。請將 CheckFileCompliance 視為管線內快速的檢查,並仍然使用像 veraPDF 這樣的獨立工具來驗證候選發布版本 (release candidates),因為驗證器實作的規則集有重疊但不完全相同;當兩者意見不合時,發現結果的文字通常會指明該去閱讀哪一個條款
從這一切之中,歸結出一個定序的要點。簽署和加蓋時間戳記不是單一的過程:先寫入基線簽章,然後一個獨立的時間戳記處理程序會在保留的 /Contents 空間內擴充 CMS,這正是為什麼前面的保留位元組行帶有這麼大的份量。對於建立在此工作台之上的時間戳記和長期驗證層,PAdES 簽章與驗證演練帶領簽章從基線走向 B-LT,而預檢的另一半在 PDF/A 與 PDF/UA 預檢指南中有更深入的探討。完整的 API 文件與試用版下載,都在 PDFlibPas 產品頁面上