前言
传统签名方式,用户拿钢笔在支票上写上自己的名字,然后这个支票就可以到银行进行兑换。
传统验签方式,请专业的笔迹鉴定专家验证笔迹为用户所写。
电子签名方式,在pdf的指定位置加上个人的电子签名图片。
例如,张三在租房的pdf合同上,加盖上自己的名字——扫描的张三对应的图片。
那有人就会想,如果只是把电子签名放到pdf文件的固定位置,那只要把对方签过的电子签名的图扣下来,就可以轻易的伪造别人的电子签名。
为了解决这个问题,就需要有一种手段来证明这个电子签名是张三盖上去的,而不是其他任何人盖上去的,并且在张三盖上去后没人改过它。
这就引出了电子签中的2个核心问题:
电子签是如何证明谁签的。
电子签是如何防篡改的。
接下来将一一介绍在pdf中,如何解决这2个问题。
背景知识
要理解电子签是如何解决上面2个问题和电子签的核心原理,首先需要补一些密码学场景的背景知识。
签名
私钥加密、公钥解密,即为签名。
通常情况下,你生成公私钥对后,会把公钥广播出去,告诉其他人这是你的公钥,当你想证明某个东西是你发出的,你可以先用私钥对要发出的内容加密,此时网络上其他人想验证这个内容是不是你阅读后签名过的内容时,就使用你的公钥对其解密,解密成功就说明是你加密后发出的。其他任何人的公钥都无法对你发出的内容解密成功。
加密传输
公钥加密、私钥解密,即为加密传输。
你想让别人秘密给你传递一段内容,你把自己的公钥给别人,让别人用你的公钥加密内容后发出,此时网络上任何人都无法解密这段加密过的内容,只有持有私钥的你可以解密。
证书相关知识
- certificate:证书,证书里最关键的信息就是证书拥有者的公钥,还有谁颁发的这个证书,颁发的有效期信息,拥有者用私钥对该证书信息签的名。
- root Certificate authority(CA):根证书颁发权威机构,是整个信任链上被无条件信任的机构,能够为其他机构、服务器、个人颁发可信任的证书。在浏览器中,可以看到当前浏览器信任的各个根证书
- intermediate certificate(ICAs):中间证书,通常是由根证书签名过的证书,沿着证书信任链解密,能指回根证书,如果根证书是可信的,那么中间证书也是可信的。
- end entity certificate(EE):终端实体证书,位于整个证书签名链的尾端。
核心知识
个人可以持有一张来自权威机构(CA:certificate authority)直接或间接颁发的可信任证书
单方签流程
在pdf文件中有一部分属于签名域,签名域中第一个字段为/ByteRange,该字段中存储4个数字,第一个数字代表签名域前面一段内容的起始offset,第二个数字代表从第一个数字开始往后增加的字节长度,第三个数字代表签名域后面那一段内容的开始位置相对于整个pdf文档的offset,第四个数字代表从第三个数字代表的偏移量开始往后增加的字节长度。
在pdf中执行单方签的流程如下:
- 首先将pdf文件转成字节流。
- 整个pdf文件被写到磁盘中,并且为签名域留出可写入的磁盘空间。
- 在签名域的范围确认后,4个数字就会被写入到byterange,例如[0,840,960,240],代表针对文件[0,840],以及[960,960+240]范围内的字节计算hash。在byterange区域,提前预留的多余的bytes会被刷成0。
- 根据byterange指定的范围计算hash值。
- 使用签名者的私钥对该hash值进行加密
- 签名对象会写入磁盘上的/Contents字段中。
- 把磁盘里的pdf重新加载会内存,确保磁盘里的版本与内存里的版本是一致的。
问题一:如何证明是张三签的
在签名字典的/Contents中会包含签名者的证书,证书里会包含证书所有者的信息。
问题二:如何防篡改
假如李四更改了pdf内非签名域的内容,那么本地验签时,本地计算的hash值和张三加密前的hash值就不一样了,就验签失败了。
单方签demo java 代码
首先根据rsa算法生成对应的公钥和私钥对
keytool -genkeypair
-alias "这里放你对生成的密钥库的别名"
-keyalg RSA
-keystore "生成的密钥库放置在本地的文件路径,以.p12结尾 "
-storetype PKCS12
-keysize 2048
-validity 365
-storepass store_pass
-dname "CN=姓名, O=组织名称,eg pdd, L=城市名称,eg 长沙, ST=省份 eg 陕西, C=中国"
其中各参数含义如上
生成的公钥和私钥会放置在本地的一个密钥库中,这个密钥库还有一个保护的密码
java 代码 demo
要跑下述demo 需要将其中待加盖章的pdf路径 pdfPath 调整为本地的pdf路径 将盖好章的pdf输出文件路径outPdfPath和outPdfPath2分别调整为输出的pdf文件路径
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.Image;
import com.itextpdf.text.Rectangle;
import com.itextpdf.text.pdf.PdfReader;
import com.itextpdf.text.pdf.PdfSignatureAppearance;
import com.itextpdf.text.pdf.PdfStamper;
import com.itextpdf.text.pdf.security.BouncyCastleDigest;
import com.itextpdf.text.pdf.security.DigestAlgorithms;
import com.itextpdf.text.pdf.security.ExternalDigest;
import com.itextpdf.text.pdf.security.ExternalSignature;
import com.itextpdf.text.pdf.security.MakeSignature;
import com.itextpdf.text.pdf.security.PrivateKeySignature;
import org.apache.commons.io.IOUtils;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
/**
* @author zihua
* @version FirstDemo.java, v 0.1 2025年02月18日 09:00 zihua
*/
public class FirstDemo {
public static void main(String[] args) {
String pdfPath = "${替换为你的pdf文件路径}"; // 替换为你的 PDF 文件路径
String outPutPdfPath = "${替换为期望输出的pdf文件路径1}"; // 替换为你的 PDF 文件路径
String outPutPdfPath2 = "${替换为期望输出的pdf文件路径2}"; // 替换为你的 PDF 文件路径
try {
// 打开现有 PDF 文档
PdfReader reader = new PdfReader(pdfPath);
// 加载本地密钥库和证书配置,需要提前用keytool生成
KeyStore ks = KeyStore.getInstance("pkcs12");
ks.load(new FileInputStream("${替换为你的密钥库文件地址}"), "store_pass".toCharArray());
String alias = (String) ks.aliases().nextElement();
PrivateKey pk = (PrivateKey) ks.getKey(alias, "${替换为你的密钥库保护密码}".toCharArray());
Certificate[] chain = ks.getCertificateChain(alias);
// 加载pdf文件
FileInputStream inputStream = new FileInputStream(pdfPath);
byte[] srcBytes = IOUtils.toByteArray(inputStream);
// 给定印章图片和用印位置进行签章
srcBytes = stampAndSign(reader, srcBytes, chain, pk, DigestAlgorithms.SHA256, MakeSignature.CryptoStandard.CMS, "Signature1", "${替换为期望的电子章文件路径}", new Rectangle(100, 700, 36, 36));
FileOutputStream outPutStream = new FileOutputStream(outPutPdfPath);
IOUtils.write(srcBytes, outPutStream);
// 加载做过一次签章的pdf文件
reader = new PdfReader(outPutPdfPath); // 读取上次输出的文档
srcBytes = stampAndSign(reader, srcBytes, chain, pk, DigestAlgorithms.SHA256, MakeSignature.CryptoStandard.CMS, "Signature2", "${替换为期望的电子章文件路径}", new Rectangle(300, 500, 36, 36));
outPutStream = new FileOutputStream(outPutPdfPath2);
IOUtils.write(srcBytes, outPutStream);
reader.close();
} catch (Exception e) {
e.printStackTrace();
}
}
public static byte[] stampAndSign(PdfReader reader1, byte[] srcBytes, Certificate[] chain, PrivateKey pk, String digestAlgorithm, MakeSignature.CryptoStandard subfilter, String fieldName, String stampImagePath, Rectangle stampPosition) throws GeneralSecurityException, IOException, DocumentException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PdfReader reader = new PdfReader(srcBytes);
// 创建签章对象
PdfStamper stamper = PdfStamper.createSignature(reader, baos, '\0', null, true);
// 加载印章图片
Image stampImage = Image.getInstance(stampImagePath);
// 设置印章图片尺寸
stampImage.scaleToFit(stampPosition.getWidth(), stampPosition.getHeight());
// 设置印章图片位置
stampImage.setAbsolutePosition(stampPosition.getLeft(), stampPosition.getBottom());
// 设置签名外观
PdfSignatureAppearance appearance = stamper.getSignatureAppearance();
appearance.setReason("测试");
appearance.setLocation("四川成都");
appearance.setVisibleSignature(new com.itextpdf.text.Rectangle(400, 600, 500, 660), 1, fieldName);
appearance.setSignatureGraphic(stampImage);
appearance.setRenderingMode(PdfSignatureAppearance.RenderingMode.GRAPHIC);
// 签名过程
ExternalDigest digest = new BouncyCastleDigest();
ExternalSignature signature = new PrivateKeySignature(pk, digestAlgorithm, null);
MakeSignature.signDetached(appearance, digest, signature, chain, null, null, null, 0, subfilter);
// 完成后关闭
stamper.close();
return baos.toByteArray();
}
}
多方签
一些qa
- 签名域里的图片是否是可以伪造的?
如果签名域里的图片发生了变化,只要4元组的大小能够维持不变,验签还是可以成功的。即相当于合同的内容未发生改变,哪怕签上去的字,被抹黑了一个字,合同的内容真实性还是存在的。
参考资料
下载后的文件如下