数字签名解决完整性和身份认证问题。但在XML场景里,直接对原始字节做签名会带来麻烦:XML文档允许空白、属性顺序、命名空间前缀不同而逻辑等价,同一个文档可以有多种字节表示。如果签名覆盖物理字节,任何格式化或前缀调整都会导致验签失败。XML数字签名规范XMLDSig为此设计了一套先规范化再签名的流程,保证签的是XML的逻辑内容而不是某一种具体序列化结果。

XMLDSig 的签名结构与核心元素
XML Signature标准定义了三种签名位置模式:Enveloped签名嵌入在被签名的XML文档内部;Enveloping签名把被签名数据包裹在Object元素里;Detached签名则与被签名资源分离,通过URI引用关联。实际系统里Enveloped模式最常见,它适合对SOAP消息、SAML断言这类XML报文做整体保护。
一个完整XML签名以Signature为根元素,内部包含SignedInfo、SignatureValue、KeyInfo和可选的Object。SignedInfo是核心,它记录了签名算法、规范化算法以及所有被签名资源的引用。Reference元素通过URI属性指向待签名数据,内部可以包含Transforms变换链、DigestMethod摘要算法和DigestValue摘要值。下面是一个典型的Enveloped签名结构:
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
<SignedInfo>
<CanonicalizationMethod Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
<SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
<Reference URI="">
<Transforms>
<Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
</Transforms>
<DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
<DigestValue>base64摘要值</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>base64签名值</SignatureValue>
<KeyInfo>
<KeyValue>
<RSAKeyValue>
<Modulus>base64模数</Modulus>
<Exponent>AQAB</Exponent>
</RSAKeyValue>
</KeyValue>
</KeyInfo>
</Signature>
SignedInfo是整个签名过程最先被处理的对象。CanonicalizationMethod指定对SignedInfo做规范化的算法,SignatureMethod指定签名算法,例如RSA-SHA256。Reference列表则让一个签名可以同时保护多个资源,每个资源独立计算摘要,避免某个资源内容改变导致全部引用失效。SignatureValue保存对规范化后的SignedInfo执行签名算法得到的值,它不直接覆盖被签名内容,而是覆盖签名元数据,这是XML签名与传统二进制签名的重要区别。
KeyInfo用来携带验证所需密钥信息,可以包含KeyValue、X509Data或密钥名称。它不强制提供,接收方可以根据上下文查找公钥。但实际应用中建议在KeyInfo中放入证书或公钥,方便离线验证。
规范化与变换为什么是签名成败的关键
XML文档中以下两种写法在逻辑上完全等价:属性顺序不同、命名空间前缀不同、单引号和双引号互换、空元素写成<tag/>或<tag></tag>、空白字符差异等。如果直接对字节流做哈希,这些差异都会导致摘要不同,验签失败。规范化算法Canonical XML(简称C14N)的作用就是把逻辑等价的XML转换成统一的物理表示。
C14N 1.0规定了一系列规则,包括:使用UTF-8编码;把换行符统一为
;属性值中的空白字符保留但标记空白;属性按照命名空间URI和本地名排序;没有命名空间前缀的默认命名空间也参与规范化;注释默认不保留。经过规范化后,两个逻辑等价的文档会生成相同的字节序列。例如下面两段XML规范化后结果一致:
<?xml version="1.0"?> <ds:root xmlns:ds="urn:demo" attr1="v1" attr2="v2"> <ds:child>text</ds:child> </ds:root>
<?xml version="1.0"?> <ns:root xmlns:ns="urn:demo" attr2="v2" attr1="v1"> <ns:child>text</ns:child> </ns:root>
不过C14N在处理命名空间上下文时存在已知问题。如果被签名节点依赖祖先节点的命名空间声明,但签名只覆盖子树,则祖先的命名空间变化会影响验证结果。Exclusive C14N通过只输出实际用到的命名空间声明来解决这个问题,适合签名某个片段而不是整个文档的场景。
Transforms变换链让签名过程更灵活。Enveloped签名变换会先删除Signature元素自身,避免签名值在计算摘要后还被嵌入的签名元素影响。XPath变换可以按条件选择节点集合,只保护文档中特定部分。变换按顺序执行,前一个输出作为后一个输入,最后的结果才交给摘要算法。因此在设计签名时,必须确保发送方和接收方使用完全相同的变换顺序和参数,否则摘要无法匹配。
签名生成与验证的完整流程
生成签名时,发送方首先为每个Reference定位URI指向的资源。如果URI为空字符串,表示指向当前文档。接着对资源依次执行Transforms中定义的变换,得到规范化的字节流,再使用DigestMethod指定的算法计算摘要,并将摘要值Base64编码后写入DigestValue。这一步完成后,SignedInfo的内容就固定下来了。
然后对SignedInfo元素本身执行CanonicalizationMethod指定的规范化,再用SignatureMethod指定的签名算法和私钥对规范化结果进行签名。签名值同样Base64编码后放入SignatureValue元素。最后把签名元素按照Enveloped、Enveloping或Detached模式插入到目标位置。整个过程可以概括为:先摘要引用资源,再签名摘要元数据。
验证方拿到XML文档后,需要先根据KeyInfo或外部配置获取公钥,然后独立执行相同的规范化、变换和摘要步骤。如果重新计算出的摘要与DigestValue不一致,说明被引用资源被篡改。再对SignedInfo规范化后的内容使用公钥验证SignatureValue,如果签名验证不通过,说明签名本身无效或SignedInfo被篡改。只有两个检查都通过,才能确认文档完整且签名可信。
下面以Java内置的XML Signature API为例,展示生成Enveloped签名的核心代码。代码中先创建XMLSignatureFactory,指定引用、变换和规范化方法,再使用DOMSignContext执行签名:
import javax.xml.crypto.dsig.*;
import javax.xml.crypto.dsig.spec.*;
import javax.xml.crypto.dsig.dom.DOMSignContext;
import java.security.*;
import java.util.*;
public class XmlSigner {
public static void sign(org.w3c.dom.Document doc, PrivateKey privateKey, PublicKey publicKey) throws Exception {
XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
List<Transform> transforms = new ArrayList<>();
transforms.add(fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
Reference ref = fac.newReference("",
fac.newDigestMethod(DigestMethod.SHA256, null),
transforms, null, null);
SignedInfo si = fac.newSignedInfo(
fac.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null),
fac.newSignatureMethod(SignatureMethod.RSA_SHA256, null),
Collections.singletonList(ref));
KeyInfoFactory kif = fac.getKeyInfoFactory();
KeyValue kv = kif.newKeyValue(publicKey);
KeyInfo ki = kif.newKeyInfo(Collections.singletonList(kv));
DOMSignContext dsc = new DOMSignContext(privateKey, doc.getDocumentElement());
XMLSignature signature = fac.newXMLSignature(si, ki);
signature.sign(dsc);
}
}
验证代码与生成逻辑基本对称。通过DOMValidateContext传入公钥,调用XMLSignature.validate即可完成摘要和签名双重校验。需要注意的是,验证使用的XMLSignature对象必须从文档中的Signature元素反序列化得到,而不是重新构建一个结构相同的对象,否则SignatureValue无法正确关联。
实际集成中,验签失败经常不是算法问题,而是规范化算法选择不当、XML解析器对实体和空白处理不一致,或者Transforms顺序写错。排查时可以先单独比较DigestValue,再比较SignedInfo规范化后的字节,逐步定位是哪个环节的字节流不一致。安全性方面,推荐使用RSA-SHA256或ECDSA-SHA256,避免使用已被淘汰的SHA-1,同时注意防重放攻击需要配合时间戳或随机数机制,XML签名本身不提供防重放保证。