Java 数字签名教程
数字签名概述
数字签名是一种加密技术,它有以下几个特点:
- 完整性:数字签名可保证消息在传输过程中不被篡改,因为对消息的任何修改都会使签名无效。
- 真实性:数字签名为签名者的身份提供了证明,因为只有持有相应私钥的人才能为给定的消息创建有效的签名。
- 不可否认性:签名的消息一经发布,发布者便无法抵赖消息的来源。
从技术上讲,签名是消息经过 hash 算法(摘要算法)之后,再使用私钥对 hash 加密后的结果。
Java 数字签名步骤
- 生成密钥对;
- 对明文进行 hash 运算,得到摘要;
- 使用私钥对摘要加密。
使用 Java 生成密钥对
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
// 密钥长度 2048 bit
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
// 私钥
PrivateKey privateKey = pair.getPrivate();
// 公钥
PublicKey publicKey = pair.getPublic();
计算明文摘要
常用的摘要算法包括:
- MD5 (Message Digest 5):产生128位的散列值,常用于校验数据完整性。
- SHA-1 (Secure Hash Algorithm 1):产生160位的散列值,被广泛使用于数字证书、SSL/TLS等协议中,但已不再被视为安全的加密算法。
- SHA-2 (Secure Hash Algorithm 2):包括SHA-256、SHA-384和SHA-512等几种不同的散列算法,分别产生256位、384位和512位的散列值,并被广泛应用于安全领域。
这里需要提醒大家注意的是一些较老的摘要算法(如MD5和SHA-1)已被证明不足以提供足够的安全性,应该尽量避免使用。
我们这里使用 SHA-256 来计算明文的摘要,代码如下:
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] messageHash = md.digest(messageBytes);
这里有一个问题,为什么要计算明文摘要?难道直接对明文加密生成签名不可以吗?因为使用摘要算法可以将原始消息压缩成固定长度的散列值,例如上面的例子使用了 sha-256 摘要算法之后,生成的 hash 只有 256 位,如果消息很长,这个散列值的长度通常远小于原始消息的长度。这样只需要对散列值进行加密,而不必对整个消息进行签名,既可以提高签名速度,又可以降低传输开销。
生成数字签名
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
byte[] digitalHash = cipher.doFinal(hashToEncrypt);
验证数字签名
第一步使用公钥解密数字签名:
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.DECRYPT_MODE, publicKey);
// 解密后得到明文的摘要
byte[] messageHash = cipher.doFinal(digitalHash);
数字签名解密得到明文的摘要之后,我们只需要将原始明文的摘要和解密后的摘要做对比,如果摘要相同则验签成功,反之验签失败。
签名和验签放到一起
最后我们举个例子,对 “hello” 字符串进行数字签名,并验证数字签名是否正确。
// 生成密钥对
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
PrivateKey privateKey = pair.getPrivate();
PublicKey publicKey = pair.getPublic();
// 签名
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] messageHash = md.digest("hello".getBytes(StandardCharsets.UTF_8));
Cipher cipher = Cipher.getInstance("RSA");
cipher.init(Cipher.ENCRYPT_MODE, privateKey);
byte[] digitalHash = cipher.doFinal(messageHash);
// 验签
Cipher cipher1 = Cipher.getInstance("RSA");
cipher1.init(Cipher.DECRYPT_MODE, publicKey);
byte[] decryptedMessageHash = cipher1.doFinal(digitalHash);
boolean isCorrect = Arrays.equals(messageHash, decryptedMessageHash);
System.out.println("验签:" + (isCorrect ? "成功" : "失败"));
试想一个场景,有人给你发了一段消息,这个消息体是 老地方见,同时附带了该消息的数字签名 D,有黑客中途劫持了消息,并将消息改为了 咖啡馆见,此时你收到了假消息,但是你可以利用签名验证消息。黑客可以修改明文,但却无法伪造签名,因为黑客没有私钥,公钥是公开的,谁都可以利用公钥来验证消息的真伪。
以上我们较为底层的 api 实现了签名和验签的过程,事实上我们可以使用 java.security.Signature 类来完成这一过程,不过需要说明的是使用该类并不能减少代码量!。。。
使用 Signature
以下是签名过程,假设要对 hello 进行数字签名,注意这里省略了生成密钥对的过程:
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] messageDigest = md.digest("hello".getBytes());
signature.update(messageDigest);
byte[] signatureBytes = signature.sign();
验证签名方法如下:
MessageDigest md1 = MessageDigest.getInstance("SHA-256");
// 如果有人将 hello 篡改成 hi,那么验签就会失败
byte[] messageDigest1 = md1.digest("hello".getBytes());
signature.initVerify(publicKey);
signature.update(messageDigest1);
boolean isCorrect = signature.verify(signatureBytes);
使用 Signature 并未带来简便,下面让我们使用 Hutool 试一试,看看 Hutool 能让我们有多糊涂。
使用 Hutool 签名验签
byte[] data = "hello".getBytes();
Sign sign = SecureUtil.sign(SignAlgorithm.SHA256withRSA);
sign.setPrivateKey(privateKey);
sign.setPublicKey(publicKey);
// 签名
byte[] signed = sign.sign(data);
// 验签
boolean verify = sign.verify(data, signed);
System.out.println(verify);
注:本文使用的 Hutool 的版本为 4.5.6,不同版本可能代码有所不同。
最后我们想介绍下密钥的存储问题,以及存储的密钥如何恢复成 PublicKey 和 PrivateKey 对象。
密钥存储和恢复
有时候我们可能习惯将密钥转化为 base64 之后存储,这个很简单不是?
String privateKeyBase64 = Base64.getEncoder().encodeToString(privateKey.getEncoded());
String publicKeyBase64 = BBase64.getEncoder().encodeToString(publicKey.getEncoded());
如何将一个 Base64 编码的密钥恢复成 PublicKey 和 PrivateKey 对象呢?这需要知道密钥对的格式, PublicKey 和 PrivateKey 都有自己的格式,查看格式可以使用 getFormat 方法:
System.out.println(privateKey.getFormat());
System.out.println(publicKey.getFormat());
以 RSA 密钥对为例,以上将分别输出:PKCS#8 和 X.509。知道了格式之后,就可以从执行恢复了。
恢复私钥
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PrivateKey privateKey = keyFactory.generatePrivate(keySpec);
恢复公钥
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyBase64));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
PublicKey publicKey = keyFactory.generatePublic(keySpec);
温馨提示:反馈需要登录