技術文章

PDFium VCL 遠端 PAdES 簽署:HSM 與雲端金鑰

PDFiumPas 把 PAdES 簽署拆成兩次呼叫,讓私鑰完全不必進入你的處理程序。PreparePadesRemoteSignature 會寫入一次增量更新,內含一個固定寬度、空白的 /Contents 佔位空間,並回傳一筆請求記錄,內含 SHA-256 文件摘要、確切的 ByteRange,以及已備妥檔案的指紋。CompletePadesRemoteSignature 則接收你的簽署服務回傳的分離式 CMS,把它填入那個保留的位置

這兩次呼叫之間,可能間隔數分鐘或數小時,處理程序可以重啟,工作也可以轉移到另一台機器上進行。這正是整套 API 要這樣設計的全部理由

為什麼遠端金鑰不能用一般的簽署呼叫?

因為 SignPadesBytes 假設簽署操作就發生在這次呼叫內部。它會建構增量更新、對 ByteRange 計算摘要、進行簽署、寫入結果,全部在回傳之前完成。當金鑰存放在 Windows 憑證存放區、或你載入的 PKCS#12 檔案中時,這樣做完全正確

但當金鑰存放在網路 HSM、由信任服務提供者操作的合格簽章建立裝置,或是需要使用者在手機上確認的雲端簽署 API 中時,這種做法就行不通了。在這些情況下,整個流程不是一次函式呼叫,而是一場對話:你送出一個摘要,某個東西去驗證一個人的身分,過一陣子才傳回一個 CMS。同步 API 無法在不阻塞一條執行緒的情況下表達「稍後」,尤其那個操作可能還需要第二重驗證因子

兩階段協定

第一階段負責備妥文件。PDFiumPas 會附加簽章欄位與值字典,在 /Contents 中保留 ContentsSize 個位元組的十六進位編碼空間,計算圍繞這個保留區的 ByteRange,並產生一筆 TPadesRemoteSigningRequest,內含 FormatVersionPreparedFingerprintDocumentDigest、四個元素構成的 ByteRangeContentsHexOffsetContentsSize

你的簽署服務唯一需要的值是 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;

跨越處理程序與機器邊界

SavePadesRemoteSigningRequestLoadPadesRemoteSigningRequest 會透過一種穩定的版本化二進位格式序列化工作階段,這正是讓整套設計真正可用、而不只是理論上正確的關鍵。一個網頁應用程式可以在某次請求中備妥文件、儲存已備妥的 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 元件頁面