PDFiumPas 把 PAdES 簽署拆成兩次呼叫,讓私鑰完全不必進入你的處理程序。PreparePadesRemoteSignature 會寫入一次增量更新,內含一個固定寬度、空白的 /Contents 佔位空間,並回傳一筆請求記錄,內含 SHA-256 文件摘要、確切的 ByteRange,以及已備妥檔案的指紋。CompletePadesRemoteSignature 則接收你的簽署服務回傳的分離式 CMS,把它填入那個保留的位置
這兩次呼叫之間,可能間隔數分鐘或數小時,處理程序可以重啟,工作也可以轉移到另一台機器上進行。這正是整套 API 要這樣設計的全部理由
為什麼遠端金鑰不能用一般的簽署呼叫?
因為 SignPadesBytes 假設簽署操作就發生在這次呼叫內部。它會建構增量更新、對 ByteRange 計算摘要、進行簽署、寫入結果,全部在回傳之前完成。當金鑰存放在 Windows 憑證存放區、或你載入的 PKCS#12 檔案中時,這樣做完全正確
但當金鑰存放在網路 HSM、由信任服務提供者操作的合格簽章建立裝置,或是需要使用者在手機上確認的雲端簽署 API 中時,這種做法就行不通了。在這些情況下,整個流程不是一次函式呼叫,而是一場對話:你送出一個摘要,某個東西去驗證一個人的身分,過一陣子才傳回一個 CMS。同步 API 無法在不阻塞一條執行緒的情況下表達「稍後」,尤其那個操作可能還需要第二重驗證因子
兩階段協定
第一階段負責備妥文件。PDFiumPas 會附加簽章欄位與值字典,在 /Contents 中保留 ContentsSize 個位元組的十六進位編碼空間,計算圍繞這個保留區的 ByteRange,並產生一筆 TPadesRemoteSigningRequest,內含 FormatVersion、PreparedFingerprint、DocumentDigest、四個元素構成的 ByteRange、ContentsHexOffset 與 ContentsSize
你的簽署服務唯一需要的值是 DocumentDigest:也就是回傳的 CAdES SignedData 必須攜帶為訊息摘要的 SHA-256。這筆記錄中的其他一切,存在的目的都是讓第二階段能夠證明它正在完成的檔案,就是計算出這個摘要的那份檔案
uses
FPdfPades;
var
Options: TPadesRemoteSignOptions;
Request: TPadesRemoteSigningRequest;
Source, Prepared, Session: TFileStream;
begin
Options := TPadesRemoteSignOptions.Default;
Options.Reason := 'Approved by finance';
Options.Location := 'Lisbon';
Options.Name := 'A. Moreira';
Options.SigningTimeUtc := NowUtc;
Options.ContentsSize := 16384; // 為 CMS 保留的十六進位位元組數
Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
Prepared := TFileStream.Create('contract.prepared.pdf', fmCreate);
try
PreparePadesRemoteSignature(Source, Prepared, Options, Request);
finally
Prepared.Free;
Source.Free;
end;
// 保存工作階段,讓稍後的執行,或另一台機器,能完成簽署
Session := TFileStream.Create('contract.signreq', fmCreate);
try
SavePadesRemoteSigningRequest(Session, Request);
finally
Session.Free;
end;
SendDigestToSigningService(Request.DocumentDigest);
end;
Complete 拒絕的是什麼,每一項檢查又為何存在?
完成階段正是遠端簽署設計最容易出錯的地方,因此驗證刻意設計得毫不寬容。CompletePadesRemoteSignature 會拒絕:指紋與請求不再相符的已備妥 PDF、不符合原本保留座標的 ByteRange、被修改過的 /Contents 分隔符、不再是空白的佔位空間、大於保留空間的 CMS、不是恰好一個 DER 值的 CMS、不受支援的 SignedData 形狀、缺少 signing-certificate-v2 屬性的內容,以及訊息摘要與備妥文件摘要不相等的 CMS
每一項都對應到一種真實的失敗情境。指紋與 ByteRange 檢查,攔截的是兩個階段之間有人重新產生了已備妥檔案的情況,這會產生一個對誰都不存在的位元組進行驗證的簽章。空白佔位空間檢查攔截的是重複完成,也就是第二個 CMS 被寫入一個已經存在簽章的位置上。訊息摘要檢查攔截的是最危險的情況:一個格式完全正確的 CMS,簽的卻是另一份文件,這正是佇列裡兩個並行簽署工作階段被搞混時會出現的結果。少了這項檢查,你可能會產生一份看起來已簽署、卻在各處驗證都失敗的檔案,更糟的情況是,它會攜帶著別人的核准
signing-certificate-v2 這項要求,屬於 PAdES 合規性問題,而不是完整性問題。ETSI EN 319 142 要求簽章憑證必須被綁定進已簽署屬性中,缺少這個屬性的 CMS,即使在密碼學上驗證通過,也不算是一個 PAdES 簽章。在完成階段就拒絕它,代表你會在這裡就發現問題,而不是等客戶的驗證報告告訴你,這個主題在 為什麼驗證器會拒絕 PAdES 簽章 中有進一步探討
var
Request: TPadesRemoteSigningRequest;
Session, Prepared, Dest: TFileStream;
CmsDer: TBytes;
begin
Session := TFileStream.Create('contract.signreq', fmOpenRead);
try
Request := LoadPadesRemoteSigningRequest(Session);
finally
Session.Free;
end;
CmsDer := FetchDetachedCmsFromService; // 由 HSM 或 TSP 回傳
Prepared := TFileStream.Create('contract.prepared.pdf', fmOpenRead);
Dest := TFileStream.Create('contract.signed.pdf', fmCreate);
try
try
CompletePadesRemoteSignature(Prepared, Dest, Request, CmsDer);
except
on E: EPadesCrypto do
// 每一種拒絕都帶有明確原因,原樣記錄下來
FailSession(E.Message);
end;
finally
Dest.Free;
Prepared.Free;
end;
end;
跨越處理程序與機器邊界
SavePadesRemoteSigningRequest 與 LoadPadesRemoteSigningRequest 會透過一種穩定的版本化二進位格式序列化工作階段,這正是讓整套設計真正可用、而不只是理論上正確的關鍵。一個網頁應用程式可以在某次請求中備妥文件、儲存已備妥的 PDF 與工作階段區塊、把摘要回傳給瀏覽器供智慧卡簽署,再於完全不同的另一次請求處理常式中完成簽署
FormatVersion 欄位正是讓這一切在升級之間仍然安全的關鍵。由較舊版本寫出的工作階段,被較新版本載入時,會被明確辨識或明確拒絕,而不會被誤讀成一種形狀不同的記錄。如果你的佇列可能把工作階段保留好幾天,就該把格式版本當成一項值得記錄的維運事實,而不是實作細節
佔位空間的大小該怎麼抓
ContentsSize 是你唯一需要仔細考慮的參數,因為它必須在 CMS 存在之前就先固定下來。它計算的是十六進位編碼後的保留空間,所以一個 6 KB 的 DER CMS 至少需要 12 KB 空間,而實作將保留空間上限設在 64 MiB
保留得太少,完成階段會在你的簽署服務已經完成工作之後,才因 CMS 過大而失敗,對於按次計費的合格簽章服務而言,這代表白白浪費一次操作。保留得太多,每一份簽署完成的文件就會永遠帶著這些填充內容。合理的做法是實際量測:用你的真實憑證鏈簽署一份文件,看看 DER 的長度,把它乘以二換算成十六進位長度,再為將來若要升級到 T 級簽章所需的時間戳記權杖預留充裕空間。含有多個中繼憑證與較長 OCSP 回應的憑證鏈,成長速度往往比想像中更快
簽署完成之後還有什麼
一個完成的遠端簽章屬於 PAdES B-B 等級。長期驗證還需要一個時間戳記與驗證素材,這是另一次增量更新,會加入一個 DSS 及其對應每個簽章的 VRI 字典,說明見 搭配 RFC 3161 時間戳記與 DSS 的長期簽章。這一步是在本機完成的:它加入的是憑證、OCSP 回應與 CRL,這些都不需要用到私鑰
在正式交付之前,用與信賴方相同的程式碼路徑驗證你所產生的結果,涵蓋在 檢視 PDF 數位簽章與 PAdES 等級 中。簽署與驗證是不同的程式碼,而遠端簽署管線正是兩者最容易在沒人察覺的情況下逐漸走偏、直到外部驗證器發出警訊才被發現的地方
PDFiumPas 是圍繞 PDFium 引擎打造的 Delphi 與 Lazarus 元件,帶有原生 Pascal 實作的 PAdES 堆疊,因此簽署、加蓋時間戳記與驗證都不需要外部命令列工具。完整 API 文件與試用版請參見 PDFium Delphi 元件頁面