一 电子签章
1.1 什么是电子签章
基于《中华人民共和国电子签名法》等相关法规和技术规范,具有法律效力的电子签章一定是需要使用 CA 数字证书进行对文件签名,并把 CA 数字证书存放在签名后文件中。
如果一份签名后的电子文件中无法查看到 CA 数字证书,仅存在一个公章图片,那么就不属于法律意义上的电子签名。电子签名法规定电子文件签署时一定要使用CA数字证书,并没有要求一定需要含有电子印章图片,理论上电子签章不需要到公安局进行备案。
实际上,电子签章是在电子签名技术的基础上添加了印章图像外观,沿袭了人们所习惯的传统盖章可视效果。电子签章使用电子签名技术来保障电子文件内容的防篡改性和签署者的不可否认性。因此,电子签章中,印章图片并不是唯一鉴别是否签章的条件,还要鉴别是否使用高级电子签名技术和 CA 数字证书。
CA 数字证书是在互联网中用于识别身份的一种具有权威性的电子文档。CA 数字证书相当于现实中的身份证。
现实中,如同个人需要去公安局申请办理身份证一样,CA 数字证书需要在“电子认证服务机构”(简称 CA 机构)进行申请办理。中国工业和信息化部、工信部授权 CA 机构来制作、签发数字证书,用非对称加密的方式,生成一对密码即私钥与公开密钥,并绑定了数字证书持有者的真实身份,人们可以在电子合同的缔约过程中用它来证明自己的身份和验证对方的身份。
CA 机构颁发的数字证书为公钥证书和私钥证书:公钥证书是对外公开、任何人都可以使用的,而私钥是专属于签署人所有的。当需要签署文档时,签署人使用私钥证书对电子文件(文档哈希值)进行加密,形成电子签名。 (注:文档哈希值计算时包含待签 PDF 文档内容、印章图片和印章坐标位置信息)
哈希值是指将 PDF 文件按照一定的算法(目前主流是 SHA256 算法),形成一个唯一的文件代码,类似于人类的指纹,任何一个 PDF 文件只有一个哈希值,且不同 PDF 文件的哈希值不可能相同,而相同哈希值的 PDF 文件的内容肯定相同。哈希算法是不可逆的,从哈希值无法推导出 PDF 原文内容。
经签署人的私钥证书加密之后的 PDF 原文哈希值就是电子签名,电子签名中有签署人的姓名、身份证号码、证书有效期、公钥等信息,电子签名放在 PDF 原文的签名域中,就形成了带有电子签名的 PDF 文件。
1.2 签名流程
文件电子签名过程,如下图:
其他人收到这个文件,即可使用PDF文件的签名域中存储的公钥证书对电子签名进行解密,解密出来的文件哈希值如果与原文的哈希值一致,则代表这个文件没有被篡改。
电子签名文件验签过程,如下图:
1.3 技术选型
这块主要有两大技术体系:
开源组织 Apache 的 PDFBox。 Adobe 的 iText,其中 iText 又分为 iText5 和 iText7。
那么这两个该如何选择呢?
PDFBox 的功能相对较弱,iText5 和 iText7 的功能非常强悍。 iText5 资料网上相对较多,如果出现问题容易找到解决方案。 PDFBox 和 iText7 的网上资料相对较少,如果出现问题不易找到相关解决方案。 PDFBox 目前提供的自定义签章接口不完整;而 iText5 和 iText7 提供了处理自定义签章的相关实现。 PDFBox 只能实现把签章图片加签到 PDF 文件;iText5 和 iText7 除了可以把签章图片加签到 PDF 文件,还可以实现直接对签章进行绘制,把文件绘制到签章上。 PDFBox 和 iText5/iText7 使用的协议不一样。PDFBox 使用的是 APACHE LICENSE VERSION 2.0(Licenses);iText5/iText7 使用的是 AGPL(https://itextpdf.com/agpl)。PDFBox 免费使用,AGPL 商用收费。
因此这里松哥就以 iText5 为例来和小伙伴们演示如何给一个 PDF 文件签名。
二 实战
2.1 生成数字证书
首先我们需要生成一个数字证书。
这个数字证书我们可以利用 JDK 自带的工具生成,为了贴近实战,松哥这里使用 Java 代码生成,生成数字证书的方式如下。
首先引入 Bouncy Castle,Bouncy Castle 是一个广泛使用的开源加密库,它为 Java 平台提供了丰富的密码学算法实现,包括对称加密、非对称加密、哈希算法、数字签名等。这个库由于其广泛的算法支持和可靠性而备受信任,被许多安全应用和加密通信协议所采用 。
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk15on</artifactId>
<version>1.70</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-ext-jdk15on</artifactId>
<version>1.70</version>
</dependency>
接下来我们写一个生成数字证书的工具类,如下:
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.*;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import java.io.*;
import java.math.BigInteger;
import java.security.*;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* @author:江南一点雨
* @site:http://www.javaboy.org
* @微信公众号:江南一点雨
* @github:https://github.com/lenve
* @gitee:https://gitee.com/lenve
*/
public class PkcsUtils {
/**
* 生成证书
*
* @return
* @throws NoSuchAlgorithmException
*/
private static KeyPair getKey() throws NoSuchAlgorithmException {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA",
new BouncyCastleProvider());
generator.initialize(1024);
// 证书中的密钥 公钥和私钥
KeyPair keyPair = generator.generateKeyPair();
return keyPair;
}
/**
* 生成证书
*
* @param password
* @param issuerStr
* @param subjectStr
* @param certificateCRL
* @return
*/
public static Map<String, byte[]> createCert(String password, String issuerStr, String subjectStr, String certificateCRL) {
Map<String, byte[]> result = new HashMap<String, byte[]>();
try(ByteArrayOutputStream out= new ByteArrayOutputStream()) {
// 标志生成PKCS12证书
KeyStore keyStore = KeyStore.getInstance("PKCS12", new BouncyCastleProvider());
keyStore.load(null, null);
KeyPair keyPair = getKey();
// issuer与 subject相同的证书就是CA证书
X509Certificate cert = generateCertificateV3(issuerStr, subjectStr,
keyPair, result, certificateCRL);
// 证书序列号
keyStore.setKeyEntry("cretkey", keyPair.getPrivate(),
password.toCharArray(), new X509Certificate[]{cert});
cert.verify(keyPair.getPublic());
keyStore.store(out, password.toCharArray());
byte[] keyStoreData = out.toByteArray();
result.put("keyStoreData", keyStoreData);
return result;
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
/**
* 生成证书
* @param issuerStr
* @param subjectStr
* @param keyPair
* @param result
* @param certificateCRL
* @return
*/
public static X509Certificate generateCertificateV3(String issuerStr,
String subjectStr, KeyPair keyPair, Map<String, byte[]> result,
String certificateCRL) {
ByteArrayInputStream bint = null;
X509Certificate cert = null;
try {
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
Date notBefore = new Date();
Calendar rightNow = Calendar.getInstance();
rightNow.setTime(notBefore);
// 日期加1年
rightNow.add(Calendar.YEAR, 1);
Date notAfter = rightNow.getTime();
// 证书序列号
BigInteger serial = BigInteger.probablePrime(256, new Random());
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
new X500Name(issuerStr), serial, notBefore, notAfter,
new X500Name(subjectStr), publicKey);
JcaContentSignerBuilder jBuilder = new JcaContentSignerBuilder(
"SHA1withRSA");
SecureRandom secureRandom = new SecureRandom();
jBuilder.setSecureRandom(secureRandom);
ContentSigner singer = jBuilder.setProvider(
new BouncyCastleProvider()).build(privateKey);
// 分发点
ASN1ObjectIdentifier cRLDistributionPoints = new ASN1ObjectIdentifier(
"2.5.29.31");
GeneralName generalName = new GeneralName(
GeneralName.uniformResourceIdentifier, certificateCRL);
GeneralNames seneralNames = new GeneralNames(generalName);
DistributionPointName distributionPoint = new DistributionPointName(
seneralNames);
DistributionPoint[] points = new DistributionPoint[1];
points[0] = new DistributionPoint(distributionPoint, null, null);
CRLDistPoint cRLDistPoint = new CRLDistPoint(points);
builder.addExtension(cRLDistributionPoints, true, cRLDistPoint);
// 用途
ASN1ObjectIdentifier keyUsage = new ASN1ObjectIdentifier(
"2.5.29.15");
// | KeyUsage.nonRepudiation | KeyUsage.keyCertSign
builder.addExtension(keyUsage, true, new KeyUsage(
KeyUsage.digitalSignature | KeyUsage.keyEncipherment));
// 基本限制 X509Extension.java
ASN1ObjectIdentifier basicConstraints = new ASN1ObjectIdentifier(
"2.5.29.19");
builder.addExtension(basicConstraints, true, new BasicConstraints(
true));
X509CertificateHolder holder = builder.build(singer);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
bint = new ByteArrayInputStream(holder.toASN1Structure()
.getEncoded());
cert = (X509Certificate) cf.generateCertificate(bint);
byte[] certBuf = holder.getEncoded();
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
// 证书数据
result.put("certificateData", certBuf);
//公钥
result.put("publicKey", publicKey.getEncoded());
//私钥
result.put("privateKey", privateKey.getEncoded());
//证书有效开始时间
result.put("notBefore", format.format(notBefore).getBytes("utf-8"));
//证书有效结束时间
result.put("notAfter", format.format(notAfter).getBytes("utf-8"));
} catch (Exception e) {
e.printStackTrace();
} finally {
if (bint != null) {
try {
bint.close();
} catch (IOException e) {
}
}
}
return cert;
}
public static void main(String[] args) throws Exception {
// CN: 名字与姓氏 OU : 组织单位名称
// O :组织名称 L : 城市或区域名称 E : 电子邮件
// ST: 州或省份名称 C: 单位的两字母国家代码
String issuerStr = "CN=javaboy,OU=产品研发部,O=江南一点雨,C=CN,E=javaboy@gmail.com,L=华南,ST=深圳";
String subjectStr = "CN=javaboy,OU=产品研发部,O=江南一点雨,C=CN,E=javaboy@gmail.com,L=华南,ST=深圳";
String certificateCRL = "http://www.javaboy.org";
Map<String, byte[]> result = createCert("123456", issuerStr, subjectStr, certificateCRL);
FileOutputStream outPutStream = new FileOutputStream("keystore.p12");
outPutStream.write(result.get("keyStoreData"));
outPutStream.close();
FileOutputStream fos = new FileOutputStream(new File("keystore.cer"));
fos.write(result.get("certificateData"));
fos.flush();
fos.close();
}
}
运行这个工具代码,会在我们当前工程目录下生成 keystore.p12
和 keystore.cer
两个文件。
其中 keystore.cer
文件通常是一个以 DER 或 PEM 格式存储的 X.509 公钥证书,它包含了公钥以及证书所有者的信息,如姓名、组织、地理位置等。
keystore.p12
文件是一个 PKCS#12 格式的文件,它是一个个人信息交换标准,用于存储一个或多个证书以及它们对应的私钥。.p12
文件是加密的,通常需要密码才能打开。这种文件格式便于将证书和私钥一起分发或存储,常用于需要在不同系统或设备间传输证书和私钥的场景。
总结下就是,.cer
文件通常只包含公钥证书,而 .p12
文件可以包含证书和私钥。
2.2 生成印章图片
接下来我们用 Java 代码绘制一个签章图片,如下:
public class SealSample {
public static void main(String[] args) throws Exception {
Seal seal = new Seal();
seal.setSize(200);
SealCircle sealCircle = new SealCircle();
sealCircle.setLine(4);
sealCircle.setWidth(95);
sealCircle.setHeight(95);
seal.setBorderCircle(sealCircle);
SealFont mainFont = new SealFont();
mainFont.setText("江南一点雨股份有限公司");
mainFont.setSize(22);
mainFont.setFamily("隶书");
mainFont.setSpace(22.0);
mainFont.setMargin(4);
seal.setMainFont(mainFont);
SealFont centerFont = new SealFont();
centerFont.setText("★");
centerFont.setSize(60);
seal.setCenterFont(centerFont);
SealFont titleFont = new SealFont();
titleFont.setText("财务专用章");
titleFont.setSize(16);
titleFont.setSpace(8.0);
titleFont.setMargin(54);
seal.setTitleFont(titleFont);
seal.draw("公章1.png");
}
}
❝这里涉及到的一些工具类文末可以下载。
最终生成的签章图片类似下面这样:
现在万事具备,可以给 PDF 签名了。
2.3 PDF 签名
最后,我们可以通过如下代码为 PDF 进行签名。
这里我们通过 iText 来实现电子签章,因此需要先引入 iText:
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>itextpdf</artifactId>
<version>5.5.13.4</version>
</dependency>
<dependency>
<groupId>com.itextpdf</groupId>
<artifactId>html2pdf</artifactId>
<version>5.0.5</version>
</dependency>
接下来对 PDF 文件进行签名:
public class SignPdf2 {
/**
* @param password pkcs12证书密码
* @param keyStorePath pkcs12证书路径
* @param signPdfSrc 签名pdf路径
* @param signImage 签名图片
* @param x
* @param y
* @return
*/
public static byte[] sign(String password, String keyStorePath, String signPdfSrc, String signImage,
float x, float y) {
File signPdfSrcFile = new File(signPdfSrc);
PdfReader reader = null;
ByteArrayOutputStream signPDFData = null;
PdfStamper stp = null;
FileInputStream fos = null;
try {
BouncyCastleProvider provider = new BouncyCastleProvider();
Security.addProvider(provider);
KeyStore ks = KeyStore.getInstance("PKCS12", new BouncyCastleProvider());
fos = new FileInputStream(keyStorePath);
// 私钥密码 为Pkcs生成证书是的私钥密码 123456
ks.load(fos, password.toCharArray());
String alias = (String) ks.aliases().nextElement();
PrivateKey key = (PrivateKey) ks.getKey(alias, password.toCharArray());
Certificate[] chain = ks.getCertificateChain(alias);
reader = new PdfReader(signPdfSrc);
signPDFData = new ByteArrayOutputStream();
// 临时pdf文件
File temp = new File(signPdfSrcFile.getParent(), System.currentTimeMillis() + ".pdf");
stp = PdfStamper.createSignature(reader, signPDFData, '\0', temp, true);
stp.setFullCompression();
PdfSignatureAppearance sap = stp.getSignatureAppearance();
sap.setReason("数字签名,不可改变");
// 使用png格式透明图片
Image image = Image.getInstance(signImage);
sap.setImageScale(0);
sap.setSignatureGraphic(image);
sap.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC);
// 是对应x轴和y轴坐标
sap.setVisibleSignature(new Rectangle(x, y, x + 185, y + 68), 1,
UUID.randomUUID().toString().replaceAll("-", ""));
stp.getWriter().setCompressionLevel(5);
ExternalDigest digest = new BouncyCastleDigest();
ExternalSignature signature = new PrivateKeySignature(key, DigestAlgorithms.SHA512, provider.getName());
MakeSignature.signDetached(sap, digest, signature, chain, null, null, null, 0, MakeSignature.CryptoStandard.CADES);
stp.close();
reader.close();
return signPDFData.toByteArray();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (signPDFData != null) {
try {
signPDFData.close();
} catch (IOException e) {
}
}
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
}
}
}
return null;
}
public static void main(String[] args) throws Exception {
byte[] fileData = sign("123456", "keystore.p12",
"待签名.pdf",//
"公章1.png", 100, 290);
FileOutputStream f = new FileOutputStream(new File("已签名.pdf"));
f.write(fileData);
f.close();
}
}
这里所需要的参数基本上前文都提过了,不再多说。
从表面上看,签名结束之后,PDF 文件上多了一个印章,如下:
本质上,则是该 PDF 文件多了一个签名信息,通过 Adobe 的 PDF 软件可以查看,如下:
之所以显示签名有效性未知,是因为我们使用的是自己生成的数字证书,如果从权威机构申请的数字证书,就不会出现这个提示。
好啦,是不是很 easy?
小伙伴们在公众号后台回复 20241023,可以获取到本文完整案例。
SpringSecurity+OAuth2 实战
最新版的 Spring Security 和之前旧版的写法有了很大的变化,针对目前最新版的 Spring Security,松哥录制了一套视频教程:SpringSecurity+OAuth2 精讲。
关于松哥
9 年程序员生涯,Java 畅销书作者,华为云最具价值专家,华为开发者社区之星,GitHub 知名项目作者。
目前产品有 Java 项目课程、Java 简历指导、1V1 模拟面试等,如有需求欢迎来勾搭。