XML签名(XML-DSig)是W3C定义的一套标准,用来对XML文档或其中的部分内容进行签名,从而保证数据的完整性和不可抵赖性。它广泛应用于SOAP消息安全、SAML单点登录、电子发票、电子合同等场景。与普通的二进制签名相比,XML签名要面对一个独特的难题:同一个XML文档在语义上相同,但字节层面可能完全不同,比如属性顺序不同、命名空间前缀不同、空白字符不同。如果直接对原始字节做摘要,验证几乎必然失败。因此,理解XML签名的实现,关键在于理解它如何解决规范化问题。

XML签名的核心结构:Signature元素
一个标准的XML签名以<Signature>元素为载体,它通常包含三个核心子元素。第一个是<SignedInfo>,它记录了签名的算法、引用的数据以及这些数据的摘要值,是真正被签名的对象。第二个是<SignatureValue>,存放对SignedInfo规范化后计算摘要并用私钥加密得到的结果。第三个是<KeyInfo>,可选元素,用于携带验证方获取公钥的信息,比如证书、公钥引用等。
<SignedInfo>内部又包含<CanonicalizationMethod>(规范化算法)、<SignatureMethod>(签名算法)以及若干个<Reference>元素。每个<Reference>通过URI属性指向被签名的数据,并包含<DigestMethod>和<DigestValue>。这种设计的好处是签名的对象不是原始XML文档本身,而是SignedInfo这个经过规范化处理的稳定结构,验证方只要按照同样的算法重新计算,就能比对出数据是否被篡改。
下面是一个典型的签名结果结构:
<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/2000/09/xmldsig#rsa-sha1"/>
<Reference URI="#orderData">
<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>
<DigestValue>base64编码的摘要值</DigestValue>
</Reference>
</SignedInfo>
<SignatureValue>base64编码的签名值</SignatureValue>
</Signature>三种签名封装方式的区别与选择
XML签名根据<Signature>元素与被签数据的位置关系,分为三种形式。第一种是被封装签名(Enveloped Signature),签名元素嵌入在被签的XML文档内部,这是最常见的做法,比如一份电子合同文档末尾附上签名。它的难点在于签名时必须排除<Signature>元素自身,否则签名值一变,文档就变,摘要就永远对不上,标准通过transform过滤来解决。
第二种是封装签名(Enveloping Signature),被签数据被放在<Signature>内部的<Object>元素里,整个文档的根元素就是<Signature>。这种方式适合数据本身比较小、需要整体打包传递的场景。第三种是分离签名(Detached Signature),签名与数据完全分离,通过URI引用外部资源,适合数据已经存在、只需附加签名的场合,但要注意外部资源可能被替换的风险。
选择时可以参考这样的原则:如果签名和文档要作为一个整体流转,用被封装签名;如果要把任意内容打包进一个签名容器,用封装签名;如果签名和数据分别存储,用分离签名。实际项目中电子发票、电子合同大多采用被封装签名。
使用Java实现XML签名的生成
Java从JDK 6开始内置了对XML数字签名的支持,位于javax.xml.crypto.dsig包下,不需要额外引入第三方库。实现步骤大致分四步:加载证书和私钥、创建签名工厂并组装SignedInfo、指定Reference和Transform、调用签名方法输出结果。
完整示例如下:
import javax.xml.crypto.dsig.*;
import javax.xml.crypto.dsig.keyinfo.*;
import javax.xml.crypto.dsig.spec.*;
import java.io.FileInputStream;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.List;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMResult;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
public class XmlSignDemo {
public static Document sign(Document doc, String keystorePath, String password) throws Exception {
// 1. 从密钥库加载私钥和证书
KeyStore ks = KeyStore.getInstance("PKCS12");
try (FileInputStream fis = new FileInputStream(keystorePath)) {
ks.load(fis, password.toCharArray());
}
String alias = ks.aliases().nextElement();
PrivateKey privateKey = (PrivateKey) ks.getKey(alias, password.toCharArray());
X509Certificate cert = (X509Certificate) ks.getCertificate(alias);
// 2. 创建签名工厂,指定规范化算法和签名算法
XMLSignatureFactory factory = XMLSignatureFactory.getInstance("DOM");
CanonicalizationMethod c14n = factory.newCanonicalizationMethod(
CanonicalizationMethod.INCLUSIVE,
(C14NMethodParameterSpec) null);
SignatureMethod signMethod = factory.newSignatureMethod(
SignatureMethod.RSA_SHA256, null);
SignedInfo signedInfo = factory.newSignedInfo(c14n, signMethod,
Collections.singletonList(buildReference(factory)));
// 3. 组装KeyInfo,放入X509证书供验证方使用
KeyInfoFactory kiFactory = factory.getKeyInfoFactory();
X509Data x509Data = kiFactory.newX509Data(Collections.singletonList(cert));
KeyInfo keyInfo = kiFactory.newKeyInfo(Collections.singletonList(x509Data));
// 4. 执行签名,被封装签名会自动排除Signature元素自身
DOMSignContext signContext = new DOMSignContext(privateKey, doc.getDocumentElement());
XMLSignature signature = factory.newXMLSignature(signedInfo, keyInfo);
signature.sign(signContext);
return doc;
}
private static Reference buildReference(XMLSignatureFactory factory) throws Exception {
// 使用enveloped-signature变换排除签名元素本身
List<Transform> transforms = Collections.singletonList(
factory.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null));
return factory.newReference("", factory.newDigestMethod(DigestMethod.SHA256, null),
transforms, null, null);
}
}这段代码中有两个容易出错的点。一是Transform.ENVELOPED不能漏掉,它的作用是在计算摘要前把<Signature>节点从文档树中剔除,否则文档在签名前后内容不同,验证时必然失败。二是空字符串URI表示对整个文档签名,如果只签某个部分,应该写成#elementId这样的片段引用,并确保被签元素带有对应的id属性。
签名的验证与常见陷阱排查
验证过程和签名过程是镜像关系:先解析XML文档找到<Signature>元素,然后验证方按照SignedInfo中声明的算法,对Reference指向的数据重新计算摘要并与DigestValue比对,再使用KeyInfo中的公钥(或自己的信任证书)验证SignatureValue。验证代码相对简单:
import javax.xml.crypto.dsig.*;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import java.io.FileInputStream;
import java.security.PublicKey;
public class XmlVerifyDemo {
public static boolean verify(Document doc, PublicKey publicKey) throws Exception {
XMLSignatureFactory factory = XMLSignatureFactory.getInstance("DOM");
DOMValidateContext context = new DOMValidateContext(publicKey,
doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature").item(0));
XMLSignature signature = factory.unmarshalXMLSignature(context);
return signature.validate(context);
}
}实际项目中验证失败的原因多数不在算法本身,而在以下几个方面。第一是解析器开启了校验或默认加载外部DTD,不同环境的解析行为不一致,建议显式禁用:factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true),这同时也是防御XXE注入攻击的必要措施。第二是规范化算法不一致,签名方用Inclusive而验证方期望Exclusive,命名空间上下文不同就会导致摘要不匹配,跨系统对接时一定要核对CanonicalizationMethod的URI。第三是文档在传输中被再次格式化,比如某些网关或消息中间件会对XML做美化打印,虽然语义没变,但如果签名时没有正确处理空白节点,验证就会失败,这时可以在签名前应用XPath过滤去除无关空白。
还有一个安全层面的建议:验证时不要盲目信任文档中的<KeyInfo>元素,攻击者可以自己生成密钥对并填入公钥,伪造出一份能通过数学验证的签名。正确做法是KeyInfo只用来定位证书,证书本身必须通过本地信任库或CA链校验,SAML等协议中的包裹攻击(wrapper attack)正是利用了验证方对KeyInfo的无条件信任。
总结
XML签名的实现要点可以归纳为三句话:结构上理解Signature、SignedInfo、Reference的层次关系;算法上重视规范化的选择,它是XML签名区别于普通签名的核心;工程上注意Transform配置、解析器安全和KeyInfo信任问题。掌握这些之后,无论是对接SAML单点登录、实现电子合同签章,还是给SOAP消息加上安全保障,都能比较顺利地落地。如果项目允许自选算法,建议统一使用SHA-256系列摘要配合RSA-SHA256或ECDSA-SHA256签名,逐步淘汰仍在使用SHA-1的老系统。