在这里插入图片描述

0. 引言

在数据交互中我们经常使用签名来验证传递数据的完整性和防抵赖,传统的签名信息中只包含数据值的签名,这种称为p1签名,但是某些场景下,传递的数据可能是图片、视频、文档,这时就出现除了要验证这些东西的完整性完,还要追溯文件来源,以作证文件的权威性,防止文件二次篡改等,这就需要在签名中添加上来源者信息,这种签名就是我们今天要聊到的p7签名。

1. 签名介绍

1.1 p1签名(PKCS#1)

PKCS#1主要定义了基于RSA公钥密码体制的数字签名和加密机制。在数字签名方面,PKCS#1签名通常指的是使用RSA算法对信息进行签名的过程,其基本步骤如下:

消息摘要:首先,对需要签名的数据进行哈希处理,生成一个固定长度的摘要(如使用SHA-256算法)。
签名生成:然后,使用发送方的私钥对哈希摘要进行加密,生成的加密数据即为数字签名。
签名验证:接收方使用发送方的公钥对数字签名进行解密,得到哈希摘要,并对接收到的原始数据进行同样的哈希处理,比较两个哈希摘要是否一致,以验证签名的有效性。

P1签名能够确保数据的完整性不可否认性

1.2 p7签名(PKCS#7)

PKCS#7定义了一种通用的消息语法,它可以用于数字签名、加密、消息认证码等。

P7签名值是根据特定的算法生成的,其生成签名信息(SignedData)结构中包含了内容信息、签名者信息、证书链、CRLs(证书吊销列表)、签名算法标识符、签名值等。所以对比P1签名多了签名者信息,这就使得P7签名可以额外用于验证用户身份、数据来源等。

2. p7签名实现

2.1 生成公私钥

因为p7签名本身是通过私钥生成,通过公钥来校验,所以需要先生成一对公私钥,这里基于RSA算法来生成

private static KeyPair buildKey() throws NoSuchAlgorithmException {
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
        keyPairGenerator.initialize(2048);
        return keyPairGenerator.generateKeyPair();
}

KeyPair对象中包含了公私钥, 可以通过keyPair.getPrivate().getEncoded()keyPair.getPublic().getEncoded()获取,其返回值为字节数组,通过base64转换成字符串即可

KeyPair keyPair = buildKey();
byte[] privateKey = keyPair.getPrivate().getEncoded();
byte[] publicKey = keyPair.getPublic().getEncoded();
  
String privateKeyStr = Base64.getEncoder().encodeToString(privateKey);
String publicKeyStr = Base64.getEncoder().encodeToString(publicKey);    

2.2 生成自签名证书

自签名证书即公钥证书,包含颁发者的信息,后续提供给客户用于校验签名,同时生成p7签名的时候也会证书中的信息添加到签名中

  /**
     * 签名身份
     * CN: CN是Common Name的缩写,通常用于指定域名或IP地址
     * O: O是Organization的缩写,表示组织名
     * L: L是Locality的缩写,表示城市或地区
     * ST: ST是State的缩写,表示州或省
     * C: C是Country的缩写,表示国家代码
     */
    String DN = "CN=wu.com, O=, L=贵阳, ST=贵州, C=中国";

    /**
     * 签名有效期:100年
     */
    long VALIDITY_MILLISECOND = 36500L * 24 * 60 * 60 * 1000;
/**
     * 创建自签名证书
     * @param keyPair
     * @return
     * @throws Exception
     */
    private static X509Certificate createSelfSignedCertificate(KeyPair keyPair,String dn) throws Exception {
        dn = StringUtils.isEmpty(dn) ? DN : dn;
        Date startDate = new Date();
        Date endDate = new Date(startDate.getTime() + VALIDITY_MILLISECOND);
        X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
                new X500Name(dn),
                new BigInteger(64, new SecureRandom()),
                startDate,
                endDate,
                new X500Name(dn),
                keyPair.getPublic()
        );
        ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate());
        X509CertificateHolder certHolder = certBuilder.build(signer);
        return new JcaX509CertificateConverter().getCertificate(certHolder);
    }

该证书生成出来是X509Certificate对象,为了方便存储,我们将其转换为字符串

// 创建自签名证书
X509Certificate cert = createSelfSignedCertificate(keyPair,dn);
// 生成带身份签名的公钥证书,后续提供给客户
String certStr = Base64.getEncoder().encodeToString(cert.getEncoded());      

同时该证书字符串为了满足X.509格式,要添加上前缀和后缀,即

-----BEGIN CERTIFICATE-----
xxx公钥证书
-----END CERTIFICATE-----

至此我们便有了公私钥和公钥证书

2.3 p7签名生成

接下来我们来生成p7签名字符串, 其中PUBLIC_CERT是前面生成的公钥证书字符串,PRIVATE_KEY是私钥

/**
     * 生成p7签名
     * @param data
     * @return
     * @throws Exception
     */
    public static String generate(byte[] data) throws Exception {
        Security.addProvider(new BouncyCastleProvider());
        CMSTypedData msg = new CMSProcessableByteArray(data);
        // 创建签名者信息生成器
        List<X509Certificate> certList = new ArrayList<>();
        X509Certificate cert = generateCert(PUBLIC_CERT);
        certList.add(cert);
        Store certs = new JcaCertStore(certList);
        DigestCalculatorProvider bc = new JcaDigestCalculatorProviderBuilder()
                .setProvider("BC")
                .build();
        ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA")
                .setProvider("BC")
                .build(restorePrivateKeyByStr(PRIVATE_KEY));

        // 添加签名创建时间
        Date signDate = new Date();
        ASN1EncodableVector attributes = new ASN1EncodableVector();
        attributes.add(new Attribute(CMSAttributes.signingTime, new DERSet(new DERUTCTime(signDate))));
        AttributeTable attributeTable = new AttributeTable(attributes);

        SignerInfoGenerator signerInfoGenerator = new JcaSignerInfoGeneratorBuilder(bc)
                .setSignedAttributeGenerator(new DefaultSignedAttributeTableGenerator(attributeTable))
                .build(signer, cert);
        // 创建签名生成器
        CMSSignedDataGenerator generator = new CMSSignedDataGenerator();
        generator.addSignerInfoGenerator(signerInfoGenerator);
        generator.addCertificates(certs);
        // 生成签名数据
        CMSSignedData signedData = generator.generate(msg, true);
        byte[] signedBytes = signedData.getEncoded();
        return Base64.getEncoder().encodeToString(signedBytes);
    }

其中JcaContentSignerBuilder.build方法接收的是一个私钥对象PrivateKey, 因此我们还需要书写一个将私钥字符串转换为私钥对象的方法restorePrivateKeyByStr

 /**
     * 私钥字符串生成PrivateKey对象
     * @param privateKeyStr
     * @return
     * @throws NoSuchAlgorithmException
     * @throws InvalidKeySpecException
     */
    public static PrivateKey restorePrivateKeyByStr(String privateKeyStr) throws NoSuchAlgorithmException, InvalidKeySpecException {
        // 解码私钥字符串
        byte[] privateKeyBytes = Base64.getDecoder().decode(privateKeyStr);
        // 构造PKCS8EncodedKeySpec对象
        PKCS8EncodedKeySpec pkcs8KeySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
        // 根据私钥KeySpec生成PrivateKey对象
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        return keyFactory.generatePrivate(pkcs8KeySpec);
    }

有一点需要注意的是,生成签名的时候的输入数据可能是文件,如果我们直接把文件的字节数组用来生成签名会导致数据过大而降低效率,为了实现唯一性校验又满足性能,我们可以先用hash对文件字节数组做一次摘要生成,其方法如下

/**
     * 计算文件hash值
     */
    public static String hashFile(File file) throws Exception {
        FileInputStream fis = null;
        String sha256 = null;
        try {
            fis = new FileInputStream(file);
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            byte[] buffer = new byte[1024];
            int length = -1;
            while ((length = fis.read(buffer, 0, 1024)) != -1) {
                md.update(buffer, 0, length);
            }
            byte[] digest = md.digest();
            sha256 = byte2hexLower(digest);
        } catch (Exception e) {
            e.printStackTrace();
            throw new Exception("计算文件hash值错误");
        } finally {
            try {
                if (fis != null) {
                    fis.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return sha256;
    }

2.4 签名验证

签名生成后,我们还需要进行签名验证,我们验证的思路分为以下几步:
1、下载文件时,会将文件和p7签名一起传输给用户,p7签名用户留存,服务端也可以留存一份
2、验证时,用户通过现有文件和之前的p7签名来调用验证接口来进行验证

这里同样要注意的是,之前我们说过生成p7签名时,是使用数据文件的hash摘要来生成的,所以验证的时候同样要用相同的方法来生成当前文件的hash摘要进行验证

/**
     * 验签
     *
     * @param dataBytes 验证数据
     * @param p7Sign    p7签名
     * @param cert      公钥证书
     * @throws Exception
     */
    public static SignReturn verifySign(byte[] dataBytes, String p7Sign, String cert) throws Exception {
        // 加载P7签名数据
        byte[] signature = Base64.getDecoder().decode(p7Sign);
        CMSSignedData signedData = new CMSSignedData(signature);
        // 验证签名
        SignerInformationStore signerInfos = signedData.getSignerInfos();
        SignerInformation signerInfo = signerInfos.getSigners().iterator().next();
        // 验证签名的证书
        X509Certificate certificate = generateCert(cert);
        JcaX509CertificateHolder certHolder = new JcaX509CertificateHolder(certificate);
        SignerInformationVerifier verifier = new JcaSimpleSignerInfoVerifierBuilder().build(certHolder);
        // 验证签名合法性
        boolean verify = signerInfo.verify(verifier);
        SignReturn signReturn = null;
        if (!verify) {
            signReturn = new SignReturn(false, "签名不合法");
            return signReturn;
        }
        signReturn = new SignReturn();
        // 获取签名创建时间
        AttributeTable signedAttributes = signerInfo.getSignedAttributes();
        org.bouncycastle.asn1.cms.Attribute signingTimeAttribute = signedAttributes.get(CMSAttributes.signingTime);
        ASN1UTCTime signingTime = (ASN1UTCTime) signingTimeAttribute.getAttributeValues()[0];
        signReturn.setSignCreateTime(signingTime.getDate());
        // 获取签名者的DN
        X500Name signerDN = signerInfo.getSID().getIssuer();
        signReturn.setSignCreatorInfo(signerDN.toString());
        // 验证数据一致性
        boolean dataValid = Arrays.equals(signerInfo.getContentDigest(), getDigest(dataBytes));
        signReturn.setVerifyResult(dataValid);
        signReturn.setVerifyMessage(dataValid ? "验签通过" : "数据不一致");
        return signReturn;
    }

	public static class SignReturn{
        /**
         * 验签结果
         */
        private boolean verifyResult;
        /**
         * 验签结果描述
         */
        private String verifyMessage;
        /**
         * 签名创建时间
         */
        private Date signCreateTime;
        /**
         * 签名创建者信息
         */
        private String signCreatorInfo;

        public SignReturn() {
        }

        public SignReturn(boolean verifyResult, String verifyMessage) {
            this.verifyResult = verifyResult;
            this.verifyMessage = verifyMessage;
        }

        public boolean isVerifyResult() {
            return verifyResult;
        }

        public void setVerifyResult(boolean verifyResult) {
            this.verifyResult = verifyResult;
        }

        public Date getSignCreateTime() {
            return signCreateTime;
        }

        public void setSignCreateTime(Date signCreateTime) {
            this.signCreateTime = signCreateTime;
        }

        public String getSignCreatorInfo() {
            return signCreatorInfo;
        }

        public void setSignCreatorInfo(String signCreatorInfo) {
            this.signCreatorInfo = signCreatorInfo;
        }

        public String getVerifyMessage() {
            return verifyMessage;
        }

        public void setVerifyMessage(String verifyMessage) {
            this.verifyMessage = verifyMessage;
        }

        @Override
        public String toString() {
            return "验签结果=" + verifyResult +
                    ", 验签信息='" + verifyMessage + '\'' +
                    ", 签名创建时间=" + signCreateTime +
                    ", 签名颁布者='" + signCreatorInfo ;
        }
    }

2.5 完整演示

下面我们从生成密钥开始,进行一次完整的演示

1、生成公私钥和公钥证书

其中generateKeys方法集成了所有数据的生成,具体可见下文完整代码地址

public class P7KeyGeneratorTest {
    public static void main(String[] args) throws Exception {
        Map<String, String> keys = generateKeys();
        System.out.println("P7签名私钥:"+keys.get(PRIVATE_KEY));
        System.out.println("P7签名公钥:"+keys.get(PUBLIC_KEY));
        System.out.println("P7公钥证书:"+keys.get(PUBLIC_CERT_KEY));
    }
}

在这里插入图片描述
2、根据上述生成的公私钥存放到常量类中保存
在这里插入图片描述
3、对文档文件生成签名

public static void main(String[] args) throws Exception {
        String file = "/Users/wuhanxue/Downloads/企业数据V1.0.xlsx";
        String hash = HashUtil.hashFile(file);
        String sign = createSign(hash.getBytes());
        System.out.println("签名:"+sign);
    }

在这里插入图片描述

4、用原文档以及该签名进行验证

 public static void main(String[] args) throws Exception {
        String file = "/Users/wuhanxue/Downloads/企业数据V1.0.xlsx";
        String hash = HashUtil.hashFile(file);
        String sign = createSign(hash.getBytes());
        System.out.println("签名:"+sign);
        P7SignUtil.SignReturn signReturn = verifySign(hash.getBytes(), sign, PUBLIC_CERT);
        System.out.println(signReturn);
    }

可以看到校验通过,并且可以查询出签名者信息,以此验证数据来源
在这里插入图片描述

5、将文件做略微修改,保存个副本,然后再次对比校验

public static void main(String[] args) throws Exception {
        String file = "/Users/wuhanxue/Downloads/企业数据V1.0.xlsx";
        String file2 = "/Users/wuhanxue/Downloads/企业数据V2.0.xlsx"; // 修改后文件
        String hash = HashUtil.hashFile(file);
        String hash2 = HashUtil.hashFile(file2);

        String sign = createSign(hash.getBytes());
        System.out.println("签名:"+sign);

        P7SignUtil.SignReturn signReturn = verifySign(hash2.getBytes(), sign, PUBLIC_CERT);
        System.out.println(signReturn);
    }

可以看到校验不通过了
在这里插入图片描述
测试通过

2.6 不足之处

需要注意的是,文中实现的方式是基于数据内容而言的,并不是对这个文件本身打标识,也就是说如果你将文件复制了一份,但是文件内容没变,理论上也是可以校验通过的。所以如果是针对需要实现文件唯一性的场景,那么就需要其他技术来实现。

3. 完整源码

上文完整代码可见如下地址:https://gitee.com/wuhanxue/wu_study/tree/master/demo/p7sign_demo

Logo

技术共进,成长同行——讯飞AI开发者社区

更多推荐