技術文章

在 Delphi 中製作 PAdES 數位簽章

驗證一枚 PAdES 簽章,意味著檢查三件互相獨立的事,而檢視器裡那個綠色勾勾只告訴您第三件。第一,/ByteRange 陣列必須涵蓋正確的位元組:它所指名的區段必須能重建出 CMS 摘要當初所取用的那份確切輸入,不能有任何已簽署的位元組落在它們之外。第二,CMS 裡的憑證必須串鏈到一個您信任的根憑證,並帶有 PAdES 所要求的已簽署 signing-certificate 屬性。第三,如果該設定檔宣稱有時間戳記,就必須有一枚 RFC 3161 權杖,把簽章值綁定到憑證到期之前的某個時間點上。Acrobat 把這三件事塌縮成一個圖示;一致性檢查器則把它們分開來看,而產生這些檔案的程式碼也應該如此。losLab PDF Library(PDF Library for Delphi)給您的是簽署這一側、時間戳記的重新嵌入,以及在您信任某個 ByteRange 之前用來檢視它的稽核呼叫

有一個區別幾乎絆倒了每一次初次實作 PAdES 的人,所以值得在任何程式碼之前先講明白。以 /SubFilter /adbe.pkcs7.detached 寫出來的簽章,是一枚完全站得住腳的 ISO 32000-1 §12.8 簽章,Acrobat 會回報它有效。它同時也不是一枚 PAdES 簽章,因為 ETSI EN 319 142-1 在每一個基線層級都要求 ETSI.CAdES.detached。一台 eIDAS 一致性檢查器會拒絕前者、接受後者,即使密碼學上兩者一模一樣。設定檔是文件對自己所做的一項宣稱,而在 PDF Library for Delphi 裡,把這項宣稱寫對只需要一次呼叫

是什麼把一枚 PDF 簽章變成一枚 PAdES 簽章

ETSI EN 319 142-1 在 CMS 格式之上疊出四個基線層級。PAdES-B-B 是入口:一枚位於 PDF 簽章欄位中的 CAdES 簽章,帶有 ETSI.CAdES.detached SubFilter 與一個已簽署的 signing-certificate 屬性。PAdES-B-T 在簽章值之上再加一枚 RFC 3161 時間戳記,證明這枚簽章存在於一個誰都無法回溯竄改的時間點之前。PAdES-B-LT 把驗證所需的憑證、CRL 與 OCSP 回應嵌進一個 Document Security Store,於是即使簽發的 CA 讓自己的基礎設施退役,檔案依然可被驗證。PAdES-B-LTA 則在這疊東西頂上蓋一枚文件時間戳記,在演算法逐漸走弱時重新保護已累積的證據

PDF Library for Delphi 把這些概念對映到它的簽署流程 API 上。設定檔標記是 SetSignProcessCustomSubFilter。如果您的政策需要承諾類型指示(來源證明、核可證明,或其他編號 1 到 6 的 ETSI 識別碼之一),那就走 SetSignProcessCommitmentType。明確的簽章政策用 SetSignProcessSignaturePolicy 掛上去,它收政策 OID 與其摘要。有一項預設值值得留意:摘要演算法留在自動時,函式庫會為 ETSI 與 adbe.pkcs7.detached 簽章選 SHA-256,只有在舊式的 adbe.pkcs7.sha1 路徑上才退回 SHA-1。無論如何都把它明確設定出來。稽核人員會問您用了哪種雜湊,而程式碼裡一個明確的值,比一個您得去翻手冊才解釋得清楚的預設值好辯護得多

以 PDF Library for Delphi 建立的 PAdES 基線層級階梯 B-B、B-T、B-LT 與 B-LTA,顯示每一層如何在 ETSI.CAdES.detached 核心之上再加時間戳記、DSS 證據或可續期的文件時間戳記
每一個 ETSI 基線層級都在同一個 CAdES 核心上再疊一項保證,從已簽署屬性一路到可續期的文件時間戳記

產生基線簽章

平面 API 把簽署驅動成一台一次性的狀態機:對來源檔開啟一個流程、設定它、結束並輸出成檔案、讀取結果碼。下面這段序列會產生一枚使用 SHA-256 的 PAdES-B-B 簽章。其中最要緊的那一行跟簽章本身毫無關係。它是那個刻意放大的 /Contents 預留空間,因為萬一日後這枚簽章得加上時間戳記,那正是您再也改不了的一樣東西

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('invoice.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Sig1');
    Pdf.SetSignProcessPFXFromFile(SignId, 'company.pfx', PfxPassword);
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Vienna', 'billing@example.com');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 2);          // SHA-256
    Pdf.SetSignProcessReserveContentsBytes(SignId, 8192);  // 留給日後時間戳記的空間
    Pdf.EndSignProcessToFile(SignId, 'invoice-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

當來源根本開不起來時,NewSignProcessFromFile 會傳回 0。在那之後,GetSignProcessResult 會把生產環境裡真正會發生的失敗模式分開來:4 代表 PDF 密碼錯誤,7 代表 PFX 密碼錯誤,9 代表憑證檔沒有私密金鑰,10 代表輸出路徑不可寫入,11 代表套用簽章位元組時失敗。把這個數字碼連同輸入檔名一起記進日誌,就能把一張含糊的支援工單變成一分鐘就能做完的診斷

加上那枚函式庫不會替您抓取的 RFC 3161 時間戳記

PDF Library for Delphi 不附帶任何 TSA 用戶端,而這是一條刻意畫下的界線,不是一處缺口。函式庫負責算出時間戳記機構必須會簽的那份雜湊,並在事後重新嵌入增強過的 CMS;夾在中間的 HTTP 往返與 CMS 手術則屬於呼叫方。這道切分有一個硬性的技術理由。名義上用來加入未簽署屬性的那個 Windows CryptoAPI 控制項 CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR,在 PAdES 所用的分離式 SignedData 版面上會以 CRYPT_E_INVALID_INDEX 失敗。所以增強過的 CMS 必須來自一個由您自己掌控的 CMS 編碼器。沒有任何函式庫能用一次系統呼叫就悄悄把權杖折進去,凡是宣稱做得到的,都是在您看不見的地方動那場手術

在 Delphi 中為 PAdES 簽章加上 RFC 3161 時間戳記的管線,把 PDF Library for Delphi 的雜湊計算與嵌入,跟呼叫方的 TSA 請求與 CMS 重新編碼分開,並落在預留的 /Contents 空間內
函式庫負責雜湊與重新嵌入,而您的程式碼負責抓取權杖並執行 CMS 手術,其結果必須落進那 8192 位元組的 /Contents 預留空間裡
var
  Pdf: TPDFlib;
  StsId: Integer;
  HashHex, TstDer, TsAttr, AugmentedCms: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    StsId := Pdf.NewPAdESSignatureTimeStampProcessFromFile('invoice-signed.pdf', '');
    Pdf.SetPAdESSignatureTimeStampField(StsId, 'Sig1');
    Pdf.SetPAdESSignatureTimeStampDigestAlgorithm(StsId, 2);
    HashHex := Pdf.GetPAdESSignatureValueHashHex(StsId);
    // 下面兩次呼叫都是應用程式碼:一次送往您的 TSA 的 HTTP POST,
    // 以及一次把權杖當作未簽署屬性附上去的 CMS 重新編碼
    TstDer := RequestTimeStampToken(HashHex);
    TsAttr := Pdf.BuildPAdESSignatureTimeStampAttribute(TstDer);
    AugmentedCms := AttachUnsignedAttribute(Pdf.GetPAdESSignatureCMSBytes(StsId), TsAttr);
    Pdf.SetPAdESSignatureCMSBytes(StsId, AugmentedCms);
    Pdf.EndPAdESSignatureTimeStampProcessToFile(StsId, 'invoice-bt.pdf');
    if Pdf.GetPAdESSignatureTimeStampProcessResult(StsId) <> 1 then
      raise Exception.Create('timestamp embedding failed');
    Pdf.ReleasePAdESSignatureTimeStampProcess(StsId);
  finally
    Pdf.Free;
  end;
end;

這裡要盯緊結果碼:12 代表指名的簽章欄位不存在,11 代表既有的 CMS 剖析不了,13 則代表增強過的 CMS 已經塞不進預留的 /Contents 佔位空間。13 是會痛的那一個,因為唯一的修法是重新簽署:一枚典型的時間戳記權杖連同它的憑證鏈大約 4 到 6 KB,而 B-B 那一步所做的 8192 位元組預留,存在的意義正是為了讓這一步有地方落腳

驗證從 ByteRange 開始,不是從憑證鏈開始

檢視器裡的綠色勾勾,是針對那台機器的憑證存放區所做的一項信任判斷,不是對檔案結構的裁決。程式化的驗證應該從更底層開始,從那個被增量更新弄得很微妙的問題開始:每一枚簽章實際上涵蓋了哪些位元組?這裡談到的每一項增強,不論是第二枚簽章、一個 DSS 字典,還是一枚文件時間戳記,都是經由增量更新抵達的,而每一次更新都會在先前簽章的 /ByteRange 之外附加位元組。那些附加的位元組是正當的。驗證器仍然必須依文件的修改政策把它們分類,而該政策所在的逐欄位 DocMDP 層級,可以用 GetSignatureDocMDPLevelByName 讀出來

在 Delphi 中對已簽署 PDF 做位元組版面稽核,顯示 ByteRange 涵蓋的區段、被排除的 /Contents 位元組、落在範圍外的附加增量更新,以及對照檔案大小的涵蓋範圍裁決
兩段涵蓋區間加上被排除在外的簽章自身位元組,才道出真正的涵蓋範圍,而附加的更新是依 DocMDP 政策分類,不是拿來害怕的
var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  I: Integer;
  B0, B1, B2, B3, FileSize: Int64;
begin
  FileSize := TFile.GetSize('invoice-bt.pdf');  // 在 Open 之前:SignDoc 會握著共用鎖
  Doc := TPDFlibSignDoc.Create;
  try
    if not Doc.Open('invoice-bt.pdf', '', False) then
      raise Exception.Create('cannot open for audit');
    Names := TStringList.Create;
    try
      Doc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
        if Doc.GetSignatureValueObjNum(Names[I]) > 0 then   // >0 代表真的簽過
        begin
          B0 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
          B1 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
          B2 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
          B3 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
          if (B0 = 0) and (B2 + B3 = FileSize) then
            Writeln(Names[I], ': covers the file to EOF')
          else
            Writeln(Names[I], ': earlier revision, or unexpected ByteRange layout');
        end;
    finally
      Names.Free;
    end;
    Doc.Close;
  finally
    Doc.Free;
  end;
end;

這條稽核路徑上住著兩個陷阱。TPDFlibSignDoc.Open 會以獨占共用鎖握住檔案,所以一個同時想把原始檔案位元組雜湊起來做 CMS 驗證的驗證器,必須先把檔案讀進記憶體,才能開啟它來稽核。把順序倒過來,讀取就會敗在一個您自己設下的鎖上。第二個陷阱是無聲的而非吵鬧的:平面 API 那邊的對應函式 GetSignProcessByteRange 傳回 Integer,而底層的位移量是 Int64,所以超過 2 GB 之後,這個平面呼叫會一聲不吭地截斷,這也就是為什麼這個範例改用稽核類別把位移量取出來。還有一項缺席也值得點名。平面層完全沒有 VerifySignature 包裝。密碼學上的裁決來自類別層級的 TPDFlibSignatureVerifier,它傳回 vsValidvsInvalidvsUnknown,或者來自一個您的法遵政策已經信任的外部驗證器

長期驗證:DSS、VRI 與文件時間戳記

PAdES-B-LT 之所以存在,是因為撤銷基礎設施終有一死。ETSI EN 319 142-1 §5.4.2.2 規定了 Document Security Store:一個文件層級的字典,承載憑證、CRL 與 OCSP 回應,並可選擇性地透過以每枚簽章的 /Contents 雜湊為索引鍵的 VRI 項目,逐簽章編索引。PDF Library for Delphi 的流程與時間戳記的設計如出一轍。NewPAdESDSSProcessFromFile 開啟流程;AddPAdESDSSCertificateAddPAdESDSSCRLAddPAdESDSSOCSP 接收 DER 二進位資料;AddPAdESDSSVRI 把選定的材料綁到某一枚簽章上;EndPAdESDSSProcessToFile 把一切寫成一次增量更新。困難的部分仍然留在您這一側。抓取撤銷材料,以及判斷它是否新鮮到值得嵌進去,是呼叫方的工作。函式庫保證的是這些字典在結構上合規,它無法保證您的 OCSP 回應者說的是實話

封存端的終點 B-LTA 加上的是一枚文件時間戳記:一個型別為 DocTimeStamp 而非 Sig 的獨立簽章欄位,透過 SetSignProcessDocTimeStamp 搭配預留的簽章長度產生。它不會取代 B-T 那一步的簽章時間戳記。簽章時間戳記證明的是某一枚特定簽章何時存在;文件時間戳記保護的則是整份檔案,DSS 證據也包含在內,而它正是長期封存每隔幾年就要在演算法走弱時續期的那個元素。一份成熟的封存設定檔兩者都會帶。對於早於這些結構的閱讀器,TPDFlibSignDoc.EnsurePAdESExtensions 會把 ESIC 開發者延伸記進文件目錄,宣告這份檔案用到了 ETSI 定義的功能

對這一切有一種反應值得先攔下來,因為它看起來像臭蟲,其實不是。對一份 PAdES 結構完全正確的檔案,檢視器常常回報「有效性未知」。信任與結構是兩條互相獨立的軸。檢視器只是無法在那台機器上把簽署者串鏈到一個它信任的根憑證,這在私有 CA 與測試憑證的場合是家常便飯,即使此時 ByteRange 稽核與 CMS 驗證兩者都通過。修法是好好散布那張根憑證,或者當真正的目標是合格的 eIDAS 狀態時,改對照歐盟信任清單來評估,而不是去動簽署程式碼

至於稽核那一側的視角,也就是跨一整批語料列舉簽章欄位、傾印 ByteRange 版面,並大量讀取 DocMDP 層級,請看姊妹篇 法遵與簽署工作臺。既要簽署又必須滿足封存政策的文件,屬於 Delphi 中的 PDF/A 與 PDF/UA 預檢所描述的工作流程。完整的 API 文件與評估版下載請見 losLab PDF Library for Delphi 產品頁