HotXLS 在不安装 Excel 的 Delphi 环境里验证并重签 Excel 数字签名。lxSignatureOpcIntegrity 单元直接从工作簿字节检查 ECMA-376 Part 2 包签名:对规范化 SignedInfo 的 RSA-SHA256、每条 SHA-256 Reference 摘要,以及每个签名选中的确切部件。证书链信任是单独、显式的一步;编辑或重签只发布能再次通过验证的输出
常见触发是长到超出桌面软件的文档流程。主管在 Excel 里签了一份预算工作簿,Delphi 服务导入数字前要确认签名,第三步还要改一张表并用服务证书重签。为此在服务器上自动化 Excel 既脆弱又不受支持,而把签名当 yes/no 标志会掩盖真正要紧的两个问题:签字者对哪些字节做了承诺,以及你是否信任握着那把钥匙的人
OPC 包签名到底覆盖什么?
OPC 包签名只覆盖其 Manifest 列出的部件,不是整个 XLSX 文件。ECMA-376 Part 2 §13(Digital Signatures)在包里放一个签名 origin 部件,通常是 /_xmlsignatures/origin.sigs,带指向一个或多个签名部件(如 /_xmlsignatures/sig1.xml)的关系。每个签名部件是一个 W3C XML-DSig Signature,其 SignedInfo 引用内部的 idPackageObject,而那个 Object 携带一份 Manifest——由指向各个包部件的 Reference 组成,每条带自己的摘要
HotXLS 支持三种 Reference 绑定部件的方式,它们不可互换。raw Reference 哈希存储的部件字节。canonical XML Reference 哈希 Canonical XML(不含注释)形态,属性顺序与命名空间声明的写法从此无关紧要。relationship Reference 先应用 OPC RelationshipTransform 再规范化,该变换只保留被其 SourceId 或 SourceType 选择器匹配的关系。选择器只认证它选中的那些关系:那个 .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 校验一文描述的同一套失败即关闭读取,部件查找遵循 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 是签名者自报的数据,不是受信时间戳。任何人都能生成自签证书、签一份工作簿、通过完整性检查
信任是带着调用方自有策略的另一次调用。XlsDefaultSignatureOpcTrustPolicy 返回失败即关闭的策略:无锚、无验证时间、文档签名 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;
Windows 后端有两个自己调 WinCrypt 时容易漏的陷阱。CertVerifyCertificateChainPolicy 返回 True 只说明调用执行了;接受还需要策略状态里 dwError 为零、聚合链错误为零、叶证书上有显式 digitalSignature 密钥用法,以及要求的 EKU。HotXLS 发现缺密钥用法的证书能过普通 base 策略,所以它自己检查 DigitalSignatureUsage 和 RequiredEkuPresent。第二个陷阱:在链引擎配置里把独占根存储与 hRestrictedOther 组合,当前后端返回 E_INVALIDARG,所以 HotXLS 用独占的调用方根存储,然后把返回主链的每个元素对照你提供的 DER 字节逐一授权。只是碰巧坐在本地系统存储里的颁发者永远不会因此受信
xsotTrustedSelection 仍然只描述那个签名及其报告的覆盖。未签名的单元格和未被选中的关系可以继续变动而选中签名保持受信——这正是信任结果同时返回 PackageSha256 和 CertificateSha256 供你审计日志用的原因
不开 Excel 怎么重签或会签工作簿?
在字节数组层面,HotXLS 经显式、经授权的事务重签,Workbook 的签名保存路径复用同一批已验证管线。XlsReplaceVerifiedSignatureOpcPackage 把一个已验证签名换成新的;XlsEditAndReplaceSelectedSignatureOpcPackage 在同一事务里应用部件编辑并替换一个选中的签名;XlsAppendVerifiedSignatureOpcPackage 追加签名、同时逐字节保留每个已验证的原始字节。替换调用收 TXLSSignatureOpcReplaceAuthorization,追加调用先验证每个原始签名,它们都收 TXLSSignatureOpcIssueOptions 选择和一个签名者回调;库从不碰你的私钥
授权记录把操作绑到你批准的那个确切原始件上:ExpectedPackageSha256、ExpectedSignaturePart 和 ExpectedCertificateSha256,全部只在旧签名完整验证通过之后才比较。每个被编辑部件必须被新选择覆盖;每个未被选中的签名在调用签名者之前必须仍对暂存包验证通过;否则调用返回 xspsWouldInvalidate
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 或智能卡封装,且
// 必须返回完整的大端 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 传是明确不支持的做法,因为库没法在同一个存储里既发布包又保存证书报告。旧覆盖不被继承:新签名认证的恰好是你列出的 Reference,所以要重新决定下游消费方将依赖哪些部件
会签用同一个追加调用。自 HotXLS 2.384.296 起,传给 XlsAppendVerifiedSignatureOpcPackage 的 TXLSSignatureOpcIssueOptions.References 可以点名来自已验证原始签名清单的部件,于是复核人可以在既有签名部件上签名、串联多个会签,或把签名部件与内容部件混进一个选择。每个追加的签名只记录它的直接选择。该版本的资质认定跑了对着 .NET WindowsBase 打包验证器的 222 项边界检查与 122 项独立检查、跨 7 个输出包,确认恰好一个新签名、原始签名与证书逐字节相同
值得知道的坑与限制
最有教育意义的 bug 来自一个完全合法的字符。OPC Reference URI 把内容类型作为 query 携带,例如 /xl/worksheets/sheet1.xml?ContentType=application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml。HotXLS 早期签发器把字面 + 百分号编码成了 %2B。HotXLS 自己的 RSA、C14N 与 Reference 检查都接受结果,WindowsBase 却报 InvalidSignature;只改这一处拼写的对照样本从 InvalidSignature 变成 Success。签发器现在保留受支持的 ASCII query 字面标点、同时仍转义 XML 属性;给任何签名者的教训是:拿一个独立的原生验证器来测,别只测你自己的
- 信任后端仅限 Windows;其他目标在完整性通过后返回
xsotTrustUnavailable - 验证器接受最大 64 MiB 的包、每部件 16 MiB、一个 origin 下最多 32 个签名部件
- 签发接受每个签名最多 256 条 Reference、合计 4096 个选择器
- 受支持的 profile 是 RSA-SHA256、SHA-256 摘要与不含注释的 Canonical XML;其他算法、额外的应用 Object 和外部 Reference 返回
xsoiUnsupportedProfile xsrvRequireCached只用缓存吊销数据;缺状态即拒绝,xsrvNotChecked不提供任何吊销保证- 签一份 Excel 也必须接受的工作簿,还会撞上 Excel 为什么修复一个有效的 XLSX里的包规则;重复的 Override 或关系 Id 在任何签名被检查之前就弄坏文件
FAQ
HotXLS 完整性检查通过等于签名者受信吗?
不等于。XlsVerifySignatureOpcPackage 证明所选部件在内嵌证书密钥下与摘要匹配,且恒报 xsstUntrusted。评估证书链要用你自己的锚和 UTC 验证时间调 XlsVerifySignatureOpcPackageWithTrust
为什么 Delphi 编辑之后 Excel 显示工作簿签名无效?
保存过的工作簿会重写被签的 XML 部件,至少一条 Reference 的 SHA-256 摘要因此变化。先用 XlsEditVerifiedOpcPackage 配 xsoePreserve 试编辑,或用 XlsEditAndReplaceSelectedSignatureOpcPackage 在一个已验证事务里编辑加重签
HotXLS 能用智能卡或 HSM 上的密钥签名吗?
能,因为签名是调用方回调。库传入规范化 SignedInfo 及其 32 字节 SHA-256 摘要,你的代码返回 RSASSA-PKCS1-v1_5 签名,私钥从不出你控制的提供程序
OPC 签名验证、信任评估、会签与经验证的重签都是 HotXLS,面向 Delphi 与 C++Builder 的原生 Excel 表格组件的一部分,它不靠 Excel 或 COM 自动化读写 XLS、XLSX、XLSB 和 ODS