技術文章

在 Delphi 中驗證與重新簽署 Excel 數位簽章

HotXLS 在 Delphi 中驗證並重新簽署 Excel 數位簽章,不需要安裝 Excel。lxSignatureOpcIntegrity 單元直接從活頁簿位元組檢查 ECMA-376 Part 2 的套件簽章:對標準化後 SignedInfo 的 RSA-SHA256、每一個 SHA-256 Reference 摘要,以及每個簽章選中的那些部件。憑證鏈信任是另一個明確的步驟,而編輯或重新簽署只發布能再次通過驗證的輸出

常見的觸發場景是長大了的文件流程。主管在 Excel 裡簽了預算活頁簿,Delphi 服務稍後要在匯入數字前確認簽章,第三步還得修正一張工作表、以服務憑證重新簽署。為此在伺服器上自動化 Excel 又脆又不受支援,而把簽章當成一個是非旗標,則會掩蓋真正要緊的兩個問題:簽署者承諾的是哪些位元組,以及您是否信任持有那把金鑰的人

OPC 套件簽章實際涵蓋什麼?

OPC 套件簽章只涵蓋其 Manifest 列出的部件,不是整個 XLSX 檔。ECMA-376 Part 2 §13(數位簽章)在套件裡放一個簽章起源部件,通常是 /_xmlsignatures/origin.sigs,帶著指向一或多個簽章部件(如 /_xmlsignatures/sig1.xml)的關係。每個簽章部件是一份 W3C XML-DSig Signature,其 SignedInfo 參照內部的 idPackageObject,而那個 Object 帶一份對各個套件部件的 References Manifest,各帶自己的摘要

HotXLS 支援 Reference 綁定部件的三種方式,它們不可互換。原始 Reference 對儲存的部件位元組做雜湊。標準 XML Reference 對 Canonical XML(不含註解)形式做雜湊,屬性順序與命名空間宣告的拼寫從此無關緊要。關係 Reference 先套用 OPC RelationshipTransform 再標準化,該轉換只保留被其 SourceId 或 SourceType 選取器比對到的關係。選取器精確認證它選中的那些關係:那份 .rels 部件的其餘內容可以隨意變動,簽章依然有效

Delphi 的 HotXLS OPC 數位簽章解剖:origin.sigs 部件指向 XML-DSig 簽章部件,其 Manifest 以原始、標準 XML 或 RelationshipTransform 參照綁定每個套件部件
三種 Reference 模式不可互換:原始模式對儲存位元組做雜湊,標準 XML 無視屬性拼寫,關係選取器則讓未選中的 .rels 條目自由變動

最後這一點,正是 HotXLS 回報覆蓋範圍而不是一個綠色勾勾的原因。TXLSSignatureOpcIntegrityResult.Coverage 是一個 TXLSSignatureOpcCoverage 記錄陣列,每個被認證的 Manifest Reference 一項,帶 ReferenceUri、PartName、ContentType、Transforms、RelationshipSourceIds 與 RelationshipSourceTypes。如果您的匯入要讀 sheet3.xml,而沒有任何描述項指名它,簽章對您即將載入的那些數字什麼都沒說

從不可變的 ZIP 快照驗證簽章

HotXLS 從一份不可變的位元組快照驗證套件,絕不從磁碟上的檔案或活的 TWorkbook 驗證。XlsVerifySignatureOpcPackage 與它的多簽章兄弟收一個 TBytes 陣列,只讀那個陣列,從不抓取外部目標、憑證、本機檔案或網路資源。重點在於:您驗證過的位元組,就是您隨後解析的位元組;如果之後重讀檔案,中間可能已經被別的行程換掉了

自製驗證器通常栽在標準化上。SignedInfo 與套件 Object 必須在完整 Signature 信封的命名空間情境裡標準化。從文件裡切出的孤立片段繼承的命名空間宣告,與驗證器在原地看到的不同,於是它的 C14N 位元組不同、摘要不同,一個完全有效的簽章就這樣失敗了。HotXLS 在驗證端與簽發端都用完整信封情境

對可能帶多個簽章的套件,XlsVerifyAllSignaturesOpcPackage 驗證每個附加的簽章並保留各自結果。只有所有被接受的簽章都驗過,匯總狀態才是 xsoiIntegrityVerified;否則是盤點順序中的第一個失敗,而每份報告仍會告訴您是哪個簽章部件失敗

uses
  System.SysUtils, System.IOUtils, lxSignatureOpcIntegrity;

function AllSignaturesIntact(const FileName: string): Boolean;
var
  Bytes: TBytes;
  Reports: TXLSSignatureOpcVerifications;
  Status: TXLSSignatureOpcIntegrityStatus;
  I, J: Integer;
begin
  Bytes := TFile.ReadAllBytes(FileName);  // 一份快照,先驗證後解析
  Status := XlsVerifyAllSignaturesOpcPackage(Bytes, Reports);
  for I := 0 to High(Reports) do
  begin
    Writeln(String(Reports[I].SignaturePart), ': ', Ord(Reports[I].Status));
    if Reports[I].Status = xsoiIntegrityVerified then
      for J := 0 to High(Reports[I].Integrity.Coverage) do
        Writeln('  covers ', String(Reports[I].Integrity.Coverage[J].PartName));
  end;
  Result := Status = xsoiIntegrityVerified;  // xsoiUnsigned 不算通過
end;

狀態值具體到可以直接行動。xsoiDigestMismatch 表示某個被選中的部件在簽署後被改動,xsoiInvalidSignature 表示 RSA 值用內嵌憑證金鑰對 SignedInfo 驗不過,xsoiPartUnavailable 表示被選中的部件不存在,xsoiUnsupportedProfile 或 xsoiResourceLimit 表示驗證器拒絕而不是猜測。底下的 ZIP 層套用與不可信 XLSX 檔案的 ZIP EOCD 驗證一文所述相同的 fail-closed 讀取,部件查找則遵循XLSX OPC 關係解析的規則

為什麼編輯已簽章的活頁簿會讓簽章失效?

只要被改動的位元組落進任何簽署選取範圍,編輯已簽章活頁簿就會讓簽章失效,因為該 Reference 儲存的 SHA-256 摘要不再相符。載入活頁簿再存一遍的試算表寫入器,幾乎必然碰過簽署部件:它重新序列化 XML、重新編號共用字串、改寫 workbook.xml 與內容類型清單。即使儲存格值完全一樣,位元組也不一樣,下一次驗證就回報 xsoiDigestMismatch

HotXLS 讓您在承諾編輯之前先把問題問清楚。XlsEditVerifiedOpcPackage 在已驗證的套件裡替換既有部件,然後驗證候選結果。用 xsoePreserve 時,只有簽章仍然驗得過才回傳 xsoePreserved——這發生在被編輯的部件落在所有簽署選取範圍之外——否則回傳 xsoeWouldInvalidate。用 xsoeInvalidateChanged 時,它把簽章綁定從根關係與 [Content_Types].xml 移除,以 xsoeInvalidated 回傳一個明確未簽署的套件,而不是交給您一個看起來還簽著的檔案

function EditKeepsSignature(const Signed, NewSheetXml: TBytes;
  out Edited: TBytes): Boolean;
var
  Edits: TXLSSignatureOpcEdits;
  Details: TXLSSignatureOpcEditResult;
begin
  SetLength(Edits, 1);
  Edits[0].PartName := '/xl/worksheets/sheet2.xml';
  Edits[0].Data := NewSheetXml;
  case XlsEditVerifiedOpcPackage(Signed, Edits, xsoePreserve, Edited, Details) of
    xsoePreserved:       Result := True;   // sheet2 在簽署選取範圍之外
    xsoeWouldInvalidate: Result := False;  // 有簽署摘要涵蓋 sheet2
  else
    raise Exception.CreateFmt('Edit refused, original integrity %d',
      [Ord(Details.OriginalIntegrity)]);
  end;
end;

完整性對上信任:驗過的簽章證明了什麼?

驗過的簽章證明的是:自持有私鑰的某人簽署以來,被選中的部件沒有變過——至於那個某人是誰,它什麼都不說。HotXLS 刻意把兩層分開:每個 TXLSSignatureOpcIntegrityResult 都帶 Trust = xsstUntrusted,包括成功的檢查;ClaimedSignatureTime 是簽署者自報的資料,不是受信任的時間戳。任何人都能生成自簽憑證、簽一份活頁簿、通過完整性檢查

HotXLS 在 Delphi 中分兩層驗證 Excel OPC 套件數位簽章:對不可變位元組快照做完整性摘要檢查、一律回報 xsstUntrusted,然後以呼叫方自有的原則執行 XlsVerifySignatureOpcPackageWithTrust
驗過的簽章證明被選部件未變,而信任需要您自己的錨點、一個 UTC 驗證時間與文件簽署 EKU,xsotTrustedSelection 才有意義

信任是另一個帶呼叫方自有原則的呼叫。XlsDefaultSignatureOpcTrustPolicy 回傳一個 fail-closed 原則:無錨點、無驗證時間、文件簽署 EKU 1.3.6.1.4.1.311.10.3.12,撤銷檢查為 xsrvRequireCached。XlsVerifySignatureOpcPackageWithTrust 先跑完整的完整性驗證器,再評估真正通過它的那張憑證;呼叫方無法換上別的葉憑證

uses
  System.SysUtils, System.DateUtils, lxSignatureOpcIntegrity;

function SignedUnderCorporateCA(const Bytes, RootDer, IssuingCaDer: TBytes): Boolean;
var
  Policy: TXLSSignatureOpcTrustPolicy;
  Trust: TXLSSignatureOpcTrustResult;
begin
  Policy := XlsDefaultSignatureOpcTrustPolicy;
  SetLength(Policy.TrustAnchors, 1);
  Policy.TrustAnchors[0] := RootDer;
  SetLength(Policy.Intermediates, 1);
  Policy.Intermediates[0] := IssuingCaDer;
  Policy.VerificationTimeUtc := TTimeZone.Local.ToUniversalTime(Now);

  Result := XlsVerifySignatureOpcPackageWithTrust(Bytes, Policy, Trust)
    = xsotTrustedSelection;
  if not Result then
    Writeln(Format('trust %d, integrity %d, policy error %.8x, chain errors %.8x',
      [Ord(Trust.Status), Ord(Trust.IntegrityStatus),
       Trust.NativePolicyError, Trust.NativeChainErrors]));
end;

自己叫 WinCrypt 時有兩個容易漏掉的陷阱。CertVerifyCertificateChainPolicy 回傳 True 只代表呼叫執行了;要接受,還需要原則狀態裡的 dwError 為零、匯總鏈錯誤為零、葉憑證上有明確的 digitalSignature 金鑰用途,以及要求的 EKU。HotXLS 發現缺金鑰用途的憑證可以通過普通基礎原則,所以它自己檢查 DigitalSignatureUsage 與 RequiredEkuPresent。第二個陷阱:在鏈引擎設定裡把獨佔根存放區與 hRestrictedOther 組合,在目前的後端回傳 E_INVALIDARG,所以 HotXLS 用獨佔的呼叫方根存放區,再把回傳主鏈的每個元素對照您提供的 DER 位元組授權。碰巧坐在本機系統存放區裡的簽發者永遠不會因此受信任

xsotTrustedSelection 仍然只描述那一個簽章與它回報的覆蓋範圍。未簽章的儲存格與未被選中的關係可以在簽章保持受信任的同時變動,這正是信任結果也回傳 PackageSha256 與 CertificateSha256 供您的稽核日誌使用的原因

不起動 Excel 怎麼重新簽署或副署活頁簿?

在位元組陣列層級,HotXLS 透過顯式、經授權的交易重新簽署,活頁簿的簽章存檔路徑重複使用同一批已驗證管線。XlsReplaceVerifiedSignatureOpcPackage 把一個已驗證簽章換成新的,XlsEditAndReplaceSelectedSignatureOpcPackage 在同一筆交易裡套用部件編輯並替換一個被選中的簽章,XlsAppendVerifiedSignatureOpcPackage 追加簽章的同時讓每個已驗證的原始位元組逐位元組保持原樣。替換呼叫收一個 TXLSSignatureOpcReplaceAuthorization,追加呼叫先驗證每個原始簽章,它們全都收一個 TXLSSignatureOpcIssueOptions 選擇與一個簽署者回呼;程式庫絕不碰您的私鑰

授權記錄把操作綁定到您核准的那份原始件:ExpectedPackageSha256、ExpectedSignaturePart 與 ExpectedCertificateSha256,全部只在舊簽章完全驗證之後才比對。每個被編輯的部件都必須被新選擇涵蓋,每個未被選中的簽章都必須在呼叫簽署者之前對暫存套件驗證通過,否則呼叫回傳 xspsWouldInvalidate

HotXLS 在 Delphi 中透過 XlsEditAndReplaceSelectedSignatureOpcPackage 重新簽署 Excel XLSX 套件:先驗證舊簽章,核對預期的套件、部件與憑證雜湊,再呼叫簽署者回呼
授權記錄把替換綁定到核准過的原始件,未選中的簽章必須在暫存套件上仍然驗證通過,私鑰始終留在您的 CNG、HSM 或智慧卡裡
uses
  System.SysUtils, System.DateUtils, lxHash, lxSignatureOpcIntegrity;

type
  TWorkbookResigner = class
  private
    function SignDigest(const Request: TXLSSignatureOpcSelectedReplaceRequest;
      var Signature: TBytes): TXLSSignatureOpcSignerStatus;
  public
    function Resign(const Approved, NewSheetXml, NewCertDer: TBytes;
      const OldCertSha256: WideString; var Output: TBytes): Boolean;
  end;

function TWorkbookResigner.SignDigest(
  const Request: TXLSSignatureOpcSelectedReplaceRequest;
  var Signature: TBytes): TXLSSignatureOpcSignerStatus;
begin
  // Request.NewSignature.Digest 是程式庫算出的 32 位元組 SHA-256;
  // RsaSignSha256Pkcs1 代表您自己的 CNG、HSM 或智慧卡包裝,
  // 必須回傳完整的 big-endian RSASSA-PKCS1-v1_5 簽章
  if RsaSignSha256Pkcs1(Request.NewSignature.Digest, Signature) then
    Result := xsosSigned
  else
    Result := xsosFailed;
end;

function TWorkbookResigner.Resign(const Approved, NewSheetXml, NewCertDer: TBytes;
  const OldCertSha256: WideString; var Output: TBytes): Boolean;
var
  Auth: TXLSSignatureOpcReplaceAuthorization;
  Options: TXLSSignatureOpcIssueOptions;
  Edits: TXLSSignatureOpcEdits;
  Details: TXLSSignatureOpcSelectedReplaceResult;
begin
  Auth.ExpectedPackageSha256 := XLSComputeSHA256Hex(Approved); // 或核准時存放的雜湊
  Auth.ExpectedSignaturePart := '/_xmlsignatures/sig1.xml';
  Auth.ExpectedCertificateSha256 := OldCertSha256;

  SetLength(Edits, 1);
  Edits[0].PartName := '/xl/worksheets/sheet1.xml';
  Edits[0].Data := NewSheetXml;

  SetLength(Options.References, 2);
  Options.References[0].PartName := '/xl/workbook.xml';
  Options.References[0].Mode := xsimCanonicalXml;
  Options.References[1].PartName := '/xl/worksheets/sheet1.xml';
  Options.References[1].Mode := xsimCanonicalXml;
  Options.ClaimedSignatureTimeUtc := FormatDateTime('yyyy"-"mm"-"dd"T"hh":"nn":"ss"Z"',
    TTimeZone.Local.ToUniversalTime(Now));

  Result := XlsEditAndReplaceSelectedSignatureOpcPackage(Approved, NewCertDer,
    Auth, Options, Edits, SignDigest, Output, Details) = xspsReplaced;
end;

那段代碼裡有兩個刻意為之的細節。Output 與 Details 必須是兩個變數;把 Details 的某個欄位當 Output 傳入是明確不支援的,因為程式庫無法在同一份儲存裡既發布套件又保留憑證報告。而且舊的覆蓋範圍不會被繼承:新簽章只認證您列出的 References,所以請重新決定下游消費者要依賴哪些部件

副署用的是同一個追加呼叫。從 HotXLS 2.384.296 起,傳給 XlsAppendVerifiedSignatureOpcPackage 的 TXLSSignatureOpcIssueOptions.References 可以指名來自已驗證原始簽章盤點中的部件,所以審閱者可以在既有簽章部件上簽名、串起多個副署,或在一個選擇裡混用簽章部件與內容部件。每個追加的簽章只記錄它直接的選取。該版本的資格驗證跑了 222 項邊界檢查與 122 項獨立檢查,用 .NET WindowsBase 封裝驗證器對照 7 個輸出套件,確認恰好一個新簽章、原始簽章與憑證逐位元組相同

值得知道的陷阱與限制

最有教育意義的 bug 來自一個完全合法的字元。OPC Reference URI 把內容類型當查詢攜帶,例如 /xl/worksheets/sheet1.xml?ContentType=application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml。早期的 HotXLS 簽發器把字面的 + 百分比編碼成 %2B。HotXLS 自己的 RSA、C14N 與 Reference 檢查接受了結果,WindowsBase 卻回報 InvalidSignature;一個只改動那處拼寫的受控樣本,從 InvalidSignature 變成 Success。簽發器現在保留受支援的字面 ASCII 查詢標點,同時仍對 XML 屬性做跳脫,給任何簽署器的教訓是:要對照獨立的原生驗證器測試,不能只測您自己的

  • 信任後端僅限 Windows;其他目標在完整性通過後回傳 xsotTrustUnavailable
  • 驗證器接受最大 64 MiB 的套件、單部件最大 16 MiB、一個 origin 底下最多 32 個簽章部件
  • 簽發接受每個簽章最多 256 個 References 與 4096 個合計選取器
  • 支援的 profile 是 RSA-SHA256、SHA-256 摘要與不含註解的 Canonical XML;其他演算法、額外的應用程式 Object 與外部 References 回傳 xsoiUnsupportedProfile
  • xsrvRequireCached 只用快取的撤銷資料;狀態缺失即拒絕,xsrvNotChecked 不提供任何撤銷保證
  • 簽出一份 Excel 也必須接受的活頁簿,仍會撞上Excel 為什麼修復一份有效的 XLSX裡的套件規則;重複的 Override 或關係 Id 會在任何簽章被檢查之前就弄壞檔案

FAQ

HotXLS 的完整性檢查代表簽署者受信任嗎?

不代表。XlsVerifySignatureOpcPackage 證明被選部件在內嵌憑證金鑰下與其摘要相符,且一律回報 xsstUntrusted。請用 XlsVerifySignatureOpcPackageWithTrust 配上您自己的錨點與 UTC 驗證時間來評估憑證鏈

為什麼 Delphi 編輯後 Excel 把我的活頁簿簽章顯示為無效?

存檔的活頁簿改寫了被簽署的 XML 部件,至少一個 Reference 的 SHA-256 摘要因此改變。先用 XlsEditVerifiedOpcPackage 配 xsoePreserve 試探編輯,或用 XlsEditAndReplaceSelectedSignatureOpcPackage 在一筆已驗證交易裡編輯並重新簽署

HotXLS 能用智慧卡或 HSM 上的金鑰簽章嗎?

可以,因為簽署是呼叫方回呼。程式庫傳來標準化的 SignedInfo 與其 32 位元組 SHA-256 摘要,您的代碼回傳 RSASSA-PKCS1-v1_5 簽章,私鑰從不離開您控制的 provider

OPC 簽章驗證、信任評估、副署與已驗證的重新簽署都是HotXLS,Delphi 與 C++Builder 的原生 Excel 試算表元件的一部分,它不需要 Excel 或 COM 自動化即可讀寫 XLS、XLSX、XLSB 與 ODS