广告位 · 页头(后台-模块管理-新建模块后替换此区域)

XML上传文件的数字签名如何生成和验证?

在XML文件上传场景中,仅依靠HTTPS只能保证数据在传输过程中不被窃听或篡改,文件到达服务器之后如果被内部系统修改、被中间缓存污染,或者需要追溯发送方身份,就需要更可靠的完整性证明。XML Signature标准为这类问题提供了原生方案,它把签名值、签名算法、引用摘要等信息直接嵌入XML文档,接收方可以在任何时间点独立完成验证,而不依赖传输通道。

XML上传文件的数字签名如何生成和验证?

一、XML数字签名的结构要点

XML Signature定义的根节点是<Signature>,它通常由三部分组成:<SignedInfo>保存签名算法、规范化方法以及被签名数据的引用和摘要;<SignatureValue>保存实际的签名值;<KeyInfo>保存公钥证书或密钥名称,用于验证时定位公钥。此外还可以包含<Object>节点存放额外业务信息。理解这个结构是生成和验证签名的基础,因为签名并不是对整个文件字节做简单加密,而是针对规范化后的引用数据先计算摘要,再对摘要相关结构进行非对称签名。

每个<Reference>元素通过URI属性指定被签名的数据范围。URI为空字符串表示对整个文档签名;URI以井号开头,例如#order-101,表示只签名文档中ID为order-101的元素。由于生成签名后要插入<Signature>节点,如果不做处理,签名节点本身也会成为被计算摘要的一部分,造成循环依赖,所以通常添加Enveloped变换,在计算引用摘要前把签名节点从文档中排除。

规范化算法解决的是XML等价性问题。同样一份XML,因为属性顺序、空白、命名空间声明位置不同,文本可能不同,但语义完全一致。C14N会将文档转换为确定性的字节流,保证生成方和验证方计算摘要时输入一致。常用的规范化算法有Inclusive C14N和Exclusive C14N,后者对命名空间上下文处理更精细,适合局部签名场景。

二、生成XML数字签名的完整流程

生成数字签名通常包含以下几个步骤。第一步是解析XML文档并开启命名空间感知,否则后续通过命名空间定位元素会失败。第二步是创建XMLSignatureFactory实例,这是Java标准库提供的入口。第三步是构造Reference,指定摘要算法和变换列表,摘要算法推荐使用SHA-256,变换通常加入Enveloped变换。第四步是构造SignedInfo,指定规范化方法和签名算法,例如使用Inclusive C14N配合RSA-SHA256。第五步是从密钥库加载私钥和证书,构建KeyInfo。最后执行签名并将结果写回文件。

下面是一段基于Java标准XML签名库的生成代码。代码中的密钥库使用PKCS12格式,私钥和证书从密钥库读取。引用URI设置为空字符串,表示签名整个文档,并通过Enveloped变换排除签名节点自身。

import javax.xml.crypto.dsig.*;
import javax.xml.crypto.dsig.dom.DOMSignContext;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory;
import javax.xml.crypto.dsig.spec.*;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.Document;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.List;
public class XmlSigner {
    public static void signXml(String xmlPath, String keyStorePath, String alias,
                               char[] password, String outputPath) throws Exception {
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        Document doc = dbf.newDocumentBuilder().parse(new FileInputStream(xmlPath));
        KeyStore ks = KeyStore.getInstance("PKCS12");
        ks.load(new FileInputStream(keyStorePath), password);
        PrivateKey privateKey = (PrivateKey) ks.getKey(alias, password);
        XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
        DigestMethod digestMethod = fac.newDigestMethod(DigestMethod.SHA256, null);
        Transform transform = fac.newTransform(Transform.ENVELOPED, (TransformParameterSpec) null);
        List<Transform> transforms = Collections.singletonList(transform);
        Reference ref = fac.newReference("", digestMethod, transforms, null, null);
        SignatureMethod signatureMethod = fac.newSignatureMethod(SignatureMethod.RSA_SHA256, null);
        CanonicalizationMethod c14nMethod = fac.newCanonicalizationMethod(
                CanonicalizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null);
        SignedInfo signedInfo = fac.newSignedInfo(c14nMethod, signatureMethod,
                Collections.singletonList(ref));
        KeyInfoFactory kif = fac.getKeyInfoFactory();
        X509Certificate cert = (X509Certificate) ks.getCertificate(alias);
        KeyInfo keyInfo = kif.newKeyInfo(Collections.singletonList(
                kif.newX509Data(Collections.singletonList(cert))));
        DOMSignContext signContext = new DOMSignContext(privateKey, doc.getDocumentElement());
        XMLSignature signature = fac.newXMLSignature(signedInfo, keyInfo);
        signature.sign(signContext);
        TransformerFactory tf = TransformerFactory.newInstance();
        Transformer transformer = tf.newTransformer();
        transformer.setOutputProperty(OutputKeys.INDENT, "yes");
        transformer.transform(new DOMSource(doc), new StreamResult(new FileOutputStream(outputPath)));
    }
}

代码中引用URI为空字符串,变换列表只包含Enveloped变换。执行签名后,<Signature>节点会被插入到文档根元素的末尾。输出XML时设置了缩进,但要注意:如果签名后的文档再次被重新格式化或修改空白,验证时摘要可能失败,因此签名后的文档应当作为不可变数据保存和传输。

三、验证XML数字签名的步骤与常见问题

验证过程与生成过程正好相反。首先解析XML文档,同样必须开启命名空间感知。然后通过getElementsByTagNameNS查找<Signature>元素。如果文档中没有签名节点,或使用getElementsByTagName误查,都会导致验证失败。找到签名节点后,使用DOMValidateContext和自定义的KeySelector从KeyInfo中提取公钥,再反序列化XMLSignature对象。验证包含两部分:一是验证SignatureValue,即用公钥校验SignedInfo的签名是否有效;二是遍历所有Reference,重新计算引用摘要并逐项比对。

下面给出对应的验证代码。这段代码在提取公钥时从KeyInfo中读取X509证书,并用证书公钥进行验证。实际项目中还需要进一步校验证书信任链、有效期和用途,不能只做签名的密码学校验。

import javax.xml.crypto.KeySelector;
import javax.xml.crypto.KeySelectorResult;
import javax.xml.crypto.XMLCryptoContext;
import javax.xml.crypto.dsig.*;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.X509Data;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import java.io.FileInputStream;
import java.security.Key;
import java.security.PublicKey;
import java.security.cert.X509Certificate;
import java.util.Iterator;
public class XmlVerifier {
    public static boolean verifyXml(String signedXmlPath) throws Exception {
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        Document doc = dbf.newDocumentBuilder().parse(new FileInputStream(signedXmlPath));
        NodeList nodes = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
        if (nodes.getLength() == 0) {
            throw new Exception("未找到Signature元素");
        }
        XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
        DOMValidateContext valContext = new DOMValidateContext(new X509KeySelector(), nodes.item(0));
        XMLSignature signature = fac.unmarshalXMLSignature(valContext);
        boolean coreValid = signature.validate(valContext);
        boolean refsValid = true;
        Iterator<?> refs = signature.getSignedInfo().getReferences().iterator();
        while (refs.hasNext()) {
            Reference ref = (Reference) refs.next();
            refsValid &= ref.validate(valContext);
        }
        return coreValid && refsValid;
    }
    static class X509KeySelector extends KeySelector {
        public KeySelectorResult select(KeyInfo keyInfo, KeySelector.Purpose purpose,
                                        AlgorithmMethod method, XMLCryptoContext context) {
            for (Object item : keyInfo.getContent()) {
                if (item instanceof X509Data) {
                    X509Data x509Data = (X509Data) item;
                    for (Object certObj : x509Data.getContent()) {
                        if (certObj instanceof X509Certificate) {
                            PublicKey publicKey = ((X509Certificate) certObj).getPublicKey();
                            return new SimpleKeySelectorResult(publicKey);
                        }
                    }
                }
            }
            throw new RuntimeException("未在KeyInfo中找到X509证书");
        }
    }
    static class SimpleKeySelectorResult implements KeySelectorResult {
        private PublicKey publicKey;
        SimpleKeySelectorResult(PublicKey publicKey) {
            this.publicKey = publicKey;
        }
        public Key getKey() {
            return publicKey;
        }
    }
}

实际验证中常见的失败原因主要有四类。引用摘要不匹配通常是因为签名后的文档被重新格式化、换行或修改了编码声明,导致字节流发生变化。签名值校验失败则说明公钥不匹配、算法不一致或SignedInfo被篡改。找不到Signature元素往往是没有开启命名空间感知,或者使用了错误的方法查找节点。变换算法配置不一致也会导致验证失败,例如生成时使用了Enveloped变换而验证时没有正确应用相同变换序列。

还需要特别注意XML签名包装攻击。攻击者可能保留原签名,把签名节点移动到不同位置,或者通过引用URI指向其他元素,使得验证虽然通过但业务数据已被替换。因此验证时不能只判断签名是否有效,还要检查引用URI是否精确指向业务关键元素,并确认文档中不应存在意外的多个签名节点。对于上传文件场景,建议固定签名位置或只接受根元素下的第一个签名节点。

四、安全实践与关键注意点

生成和验证XML数字签名时,算法强度是最基本的要求。应使用SHA-256作为摘要算法,RSA密钥长度至少2048位,或者使用ECDSA。SHA-1和MD5已经被认为不安全,不应继续使用。规范化算法和变换算法也必须与签名时保持一致,任何细微差别都会造成验证失败。

证书信任链验证不能省略。仅从KeyInfo中提取公钥并完成密码学校验,只能证明签名值确实由对应私钥生成,但不能证明发送方身份可信。攻击者完全可以自签一个证书并重新签名。因此验签时需要验证证书是否由可信CA签发、是否在有效期内、是否被吊销以及证书用途是否适合数字签名。如果系统部署在内部环境,可以使用预先配置的公钥固定方案,只接受指定公钥对应的签名。

XML上传场景还要结合业务做防重放和范围校验。可以为签名数据增加时间戳、随机数和请求标识,并在一段时间窗口内只接受一次。引用URI应当具体指向订单号、用户ID或文件元数据等关键元素,而不是简单使用空引用对整个文档签名。解析XML时还要禁用DOCTYPE和外部实体,防止XXE注入。只要生成和验证两个环节严格对齐,XML数字签名可以很好地保障上传文件的完整性和来源真实性。

广告位 · 文章底部(后台-模块管理替换此区域)