1. 场景
场景一:
当更新Android手机上的微信APP,系统怎么判断新的安装包就是腾讯公司发布的安装包?系统怎么判断即使是腾讯发布的安装包,但是安装包却没有被修改?这显然是非常重要的事情,如果安装包被修改过,那么用户口令、数据、银行卡等信息都可能会被窃取。
场景二:
现在很多企业内部都是通过邮件、IM来沟通,甚至下发财务、采购等指令,相关人员如何鉴别,邮件、电子合同(文档)就是老板本人发出来的呢?而不是假冒的。
如果存在一种机制,发送者对数据(安装包、文档等)进行一个“签名”或者“盖章”,而接收者根据这个签名或者盖章进行验证,从而判断数据是否正确的发送者发送的,以及数据是否被篡改,那么这些问题就迎刃而解了。并且,这种验证机制是公告开的。
这种机制是存在的,密码学上叫:数字签名。数字签名的实现,通常使用公钥算法(又称非对称加密算法),该算法的特点就是秘钥有一对:公钥和私钥,公钥是公开的,可以广播出去告诉大家,私钥是保密的,只能是自己知道,保密。因此,在实现数字签名,流程是使用私钥对数据进行签名,输出一段特定长度的数字签名(指纹),验证着使用对应的公钥、原始数据、数字签名进行运算,从而校验数据是否被篡改或者发行者身份的合法性
2. 签名算法、非对称加密、ECC与secp256k1
签名算法有比较多的选择,例如:RSA、DSA、ECC(ECDSA)等。前两者因为秘钥长度和性能的关系,现在使用越来越少,例如常见的RSA2048,秘钥长度就达到了2048bit,也就是2KB大小,在一些嵌入式场合消耗比较大,而ECC只需要224bit,因此比特币在保证数据安全性基础的算法选择上选择了ECC。
ECC也就是椭圆曲线密码学,原理上不多说了,现在很多应用场合选择了它,例如区块链,足以看出它的火热程度。
在使用ECC进行数字签名的时候,需要构造一条曲线,也可以选择标准曲线,诸如:prime256v1、secp256r1、nistp256、secp256k1等等。我们需要使用的是secp256k1,也就是比特币选择的加密曲线。
3. 秘钥的产生和载入
公钥算法的秘钥,通常不可能和我们认知的口令对等,例如:secp256k1,秘钥长度就达到了256bit,也就是32字节,记忆在脑海里,显然是不现实的。通常,我们通过程序来生成秘钥,存储到磁盘、安全设备上,然后再通过程序载入使用。
3.1 秘钥生成
在Java中,生成ECC秘钥很简单,只需要使用:KeyPairGenerator
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
// curveName这里取值:secp256k1
ECGenParameterSpec ecGenParameterSpec = new ECGenParameterSpec(curveName);
keyPairGenerator.initialize(ecGenParameterSpec, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// 获取公钥
keyPari.getPublic();
// 获取私钥
keyPair.getPrivate();
KeyPairGenerator可以设置一些算法参数,因为我们需要指定标准曲线,因此使用:ECGenParameterSpec("secp256k1")
来指定曲线。
这里显然有个问题存在,在业务的生命周期当中,秘钥始终是同一个,而上述代码,每运行一次,就重新产生一个,显然是不现实的,在实际业务中的做法就是:第一次产生一个(或者使用诸如OpenSSL一类的工具,生成一个),然后存储到磁盘上或者特殊的存储介质上,然后在程序中加载。
3.2 秘钥的存储
Java中要序列化秘钥,也是相当简单的,只要调用:getEncoded()
,它返回特定格式的byte[]数据,该格式属于标准格式,可以在大部分程序/软件中通用。
对于PrivateKey.getEncoded()
返回 PKCS #8
格式并且以DER
编码输出;
对于 PublicEncode.getEncoded()
返回 X.509
格式并且以DER
编码输出的byte[],这个时候,可以直接存储到磁盘上了。
测试代码:
KeyPair keyPair = KeyUtil.createKeyPairGenerator("secp256k1");
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
KeyUtil.savePublicKey(publicKey, "publickey.der");
KeyUtil.savePrivateKey(privateKey, "privatekey.der");
为了验证一下,我们使用:OpenSSL命令来验证一下:
打印公钥:
$ openssl pkey -inform DER -pubin -in publickey.der -text
-----BEGIN PUBLIC KEY-----
MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEDNeUU82FtdEOUjDjiX9PqRTi2HD2Dq7x
TrnTVY3Q52j+FtSJtBLp6RmEJ0dCmxd3y1igSMCx9nOrAO0vqEdBTA==
-----END PUBLIC KEY-----
Public-Key: (256 bit)
pub:
04:0c:d7:94:53:cd:85:b5:d1:0e:52:30:e3:89:7f:
4f:a9:14:e2:d8:70:f6:0e:ae:f1:4e:b9:d3:55:8d:
d0:e7:68:fe:16:d4:89:b4:12:e9:e9:19:84:27:47:
42:9b:17:77:cb:58:a0:48:c0:b1:f6:73:ab:00:ed:
2f:a8:47:41:4c
ASN1 OID: secp256k1
打印私钥:
$ openssl pkey -inform DER -in privatekey.der -text
-----BEGIN PRIVATE KEY-----
MD4CAQAwEAYHKoZIzj0CAQYFK4EEAAoEJzAlAgEBBCA9ONwt9uitCK04sqbs3MvH
3wj8B4ZIzhKDTzY2NqfDzQ==
-----END PRIVATE KEY-----
Private-Key: (256 bit)
priv:
3d:38:dc:2d:f6:e8:ad:08:ad:38:b2:a6:ec:dc:cb:
c7:df:08:fc:07:86:48:ce:12:83:4f:36:36:36:a7:
c3:cd
pub:
04:0c:d7:94:53:cd:85:b5:d1:0e:52:30:e3:89:7f:
4f:a9:14:e2:d8:70:f6:0e:ae:f1:4e:b9:d3:55:8d:
d0:e7:68:fe:16:d4:89:b4:12:e9:e9:19:84:27:47:
42:9b:17:77:cb:58:a0:48:c0:b1:f6:73:ab:00:ed:
2f:a8:47:41:4c
ASN1 OID: secp256k1
这说明,秘钥可以被别的工具识别
3.3 PEM编码的秘钥
getEncoded()
方法输出的是DER编码的二进制文件,在很多时候,我们可能为了便于交互,需要以文本编码的方式输出,这个时候PEM编码可以满足。PEM编码结构大致为BEGIN-END块结构,中间内容为Base64转换后的的DER编码内容。
Java标准库不支持PEM格式的读写,但可以使用 bouncycastle
来实现。不过,针对私钥和公钥,我们可以简单的写代码实现,这样避免引入过多的依赖。简单实现的话,只需要将:getEncoded()
输出进行Base64编码(64个字节添加换行符),然后首尾添加响应的分割字符串。下面是实现代码:
public static void savePublicKeyAsPEM(PublicKey publicKey, String name) throws Exception {
String content = Base64Util.encode(publicKey.getEncoded());
File file = new File(name);
if ( file.isFile() && file.exists() )
throw new IOException("file already exists");
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw")) {
randomAccessFile.write("-----BEGIN PUBLIC KEY-----\n".getBytes());
int i = 0;
for (; i<(content.length() - (content.length() % 64)); i+=64) {
randomAccessFile.write(content.substring(i, i + 64).getBytes());
randomAccessFile.write('\n');
}
randomAccessFile.write(content.substring(i, content.length()).getBytes());
randomAccessFile.write('\n');
randomAccessFile.write("-----END PUBLIC KEY-----".getBytes());
}
}
public static void savePrivateKeyAsPEM(PrivateKey privateKey, String name) throws Exception {
String content = Base64Util.encode(privateKey.getEncoded());
File file = new File(name);
if ( file.isFile() && file.exists() )
throw new IOException("file already exists");
try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw")) {
randomAccessFile.write("-----BEGIN PRIVATE KEY-----\n".getBytes());
int i = 0;
for (; i<(content.length() - (content.length() % 64)); i+=64) {
randomAccessFile.write(content.substring(i, i + 64).getBytes());
randomAccessFile.write('\n');
}
randomAccessFile.write(content.substring(i, content.length()).getBytes());
randomAccessFile.write('\n');
randomAccessFile.write("-----END PRIVATE KEY-----".getBytes());
}
}
为了验证生成的PEM的合法性,我们依然使用OpenSSL命令来验证:
# 打印公钥
$ openssl ec -in publickey.pem -pubin -text -noout
Private-Key: (256 bit)
pub:
04:47:e4:24:c3:97:fb:77:5d:5d:43:f0:04:c2:fe:
13:bf:f4:97:0e:b9:79:43:c9:bf:bb:72:1c:d1:ee:
bd:9a:27:42:aa:a0:d8:c0:00:9c:6f:d9:38:da:67:
0e:75:9a:8e:e4:e7:c4:67:86:f3:c0:19:64:22:47:
e9:53:f7:09:f4
ASN1 OID: secp256k1
read EC key
# 打印私钥
$ openssl ec -in privatekey.pem -text -noout
Private-Key: (256 bit)
priv:
00:83:00:e5:1c:7b:a0:34:ee:67:3c:3e:07:a1:64:
de:cc:80:d3:59:4e:a1:14:bb:86:81:f3:2e:8a:b1:
51:de:d2
pub:
04:47:e4:24:c3:97:fb:77:5d:5d:43:f0:04:c2:fe:
13:bf:f4:97:0e:b9:79:43:c9:bf:bb:72:1c:d1:ee:
bd:9a:27:42:aa:a0:d8:c0:00:9c:6f:d9:38:da:67:
0e:75:9a:8e:e4:e7:c4:67:86:f3:c0:19:64:22:47:
e9:53:f7:09:f4
ASN1 OID: secp256k1
read EC key
3.3 秘钥的加载
加载公钥和私钥,需要先从磁盘中读取成byte[],然后使用:X509EncodedKeySpec
和 PKCS8EncodedKeySpec
转换成公钥和私钥。
实例代码:
// 读取公钥, encodedKey为从文件中读取到的byte[]数组
public static PublicKey loadPublicKey(byte[] encodedKey, String algorithm)
throws NoSuchAlgorithmException, InvalidKeySpecException {
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedKey);
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
return keyFactory.generatePublic(keySpec);
}
// 读取私钥
public static PrivateKey loadPrivateKey(byte[] encodedKey, String algorithm)
throws NoSuchAlgorithmException, InvalidKeySpecException{
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encodedKey);
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
return keyFactory.generatePrivate(keySpec);
}
例如加载私钥:
PrivateKey privateKey1 = KeyUtil.loadPrivateKey(IOUtils.readBytes(
new FileInputStream("privatekey.der")), "EC");
// readBytes代码
public static byte[] readBytes(final InputStream inputStream) throws IOException {
final int BUFFER_SIZE = 1024;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
int readCount;
byte[] data = new byte[BUFFER_SIZE];
while ((readCount = inputStream.read(data, 0, data.length)) != -1) {
buffer.write(data, 0, readCount);
}
buffer.flush();
return buffer.toByteArray();
}
上述两个方法,只能处理DER编码的秘钥,如果是PEM,我们移除掉"BEGIN-END"以及换行符,然后进行Base64解码后进行处理
public static PrivateKey loadECPrivateKey(String content, String algorithm) throws Exception {
String privateKeyPEM = content.replace("-----BEGIN PRIVATE KEY-----\n", "")
.replace("-----END PRIVATE KEY-----", "").replace("\n", "");
byte[] asBytes = Base64Util.decode(privateKeyPEM);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(asBytes);
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
return keyFactory.generatePrivate(spec);
}
public static PublicKey loadECPublicKey(String content, String algorithm) throws Exception {
String strPublicKey = content.replace("-----BEGIN PUBLIC KEY-----\n", "")
.replace("-----END PUBLIC KEY-----", "").replace("\n", "");
byte[] asBytes = Base64Util.decode(strPublicKey);
X509EncodedKeySpec spec = new X509EncodedKeySpec(asBytes);
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
return (ECPublicKey) keyFactory.generatePublic(spec);
}
在大部分系统业务系统里面,频繁生成、加载秘钥的业务是不多的,但是如果做一个开放性API体系,可能用的就比较多了(例如微信、支付宝一些业务接入就需要提供公钥),而且秘钥来源软件比较多,这里可能需要深入了解:PKCS系列标准、X.509等。大家可以自行搜索相关内容。
校验数字签名
上面讲述了秘钥的生成、存储和加载,这篇的内容就是如何生成和校验数字签名。
1. Signature类
在Java中,签名和校验,都是通过: Signature 类来实现的。
该类的主要方法如下:
- getInstance(String algorithm)
工厂方法,获取Signature实例,而参数:algorithm就是签名算法的名称,这里我们使用的是:SHA256withECDSA
- initSign(PrivateKey privateKey)
- initVerify(PublicKey publicKey)
初始化成签名或者校验,Signature类的实例,在同一个阶段只能完成签名或者校验之一的任务,因此调用initSign或者initVeify让Signature进入相应的状态
- update(byte[] data)
无论是计算签名,还是校验数据,都需要传入被签名或者被校验的数据,调用该方法进行计算。注意,该方法可以调用多次,通常数据都可能非常大,不可能一次性读入内存(从磁盘上或者网络),因此我们可以对数据进行分块,一次性一KB或者合适的块进行多次调用
- sign()
获取签名,当所有的数据都调用update计算后,就可以获取签名了,返回的签名是一个byte数组
- verify(byte[] signature)
校验签名,当所有的数据都调用update计算之后,调用该方法,传递签名数据,如果签名正确,那么返回true,否则返回false,说明数据可能被篡改、伪造,或者对应的私钥不正确。
2. 实例
Java中计算签名比较简单,请阅读下面代码中的注释
public static byte[] signData(String algorithm, byte[] data, PrivateKey key) throws Exception {
Signature signer = Signature.getInstance(algorithm);
signer.initSign(key);
signer.update(data);
return (signer.sign());
}
public static boolean verifySign(String algorithm, byte[] data, PublicKey key, byte[] sig) throws Exception {
Signature signer = Signature.getInstance(algorithm);
signer.initVerify(key);
signer.update(data);
return (signer.verify(sig));
}
@Test
public void testSignVerify() throws Exception {
// 需要签名的数据
byte[] data = new byte[1000];
for (int i=0; i<data.length; i++)
data[i] = 0xa;
// 生成秘钥,在实际业务中,应该加载秘钥
KeyPair keyPair = KeyUtil.createKeyPairGenerator("secp256k1");
PublicKey publicKey1 = keyPair.getPublic();
PrivateKey privateKey1 = keyPair.getPrivate();
// 生成第二对秘钥,用于测试
keyPair = KeyUtil.createKeyPairGenerator("secp256k1");
PublicKey publicKey2 = keyPair.getPublic();
PrivateKey privateKey2 = keyPair.getPrivate();
// 计算签名
byte[] sign1 = signData("SHA256withECDSA", data, privateKey1);
byte[] sign2 = signData("SHA256withECDSA", data, privateKey1);
// sign1和sign2的内容不同,因为ECDSA在计算的时候,加入了随机数k,因此每次的值不一样
// 随机数k需要保密,并且每次不同
// 用对应的公钥验证签名,必须返回true
Assert.assertTrue(verifySign("SHA256withECDSA", data, publicKey1, sign1));
// 数据被篡改,返回false
data[1] = 0xb;
Assert.assertFalse(verifySign("SHA256withECDSA", data, publicKey1, sign1));
data[1] = 0xa;
Assert.assertTrue(verifySign("SHA256withECDSA", data, publicKey1, sign1));
// 签名被篡改,返回false
// 签名为DER格式,前三个字节是标识和数据长度,如果修改了这三个会抛出异常,无效签名格式
sign1[20] = (byte)~sign1[20];
Assert.assertFalse(verifySign("SHA256withECDSA", data, publicKey1, sign1));
// 使用其他公钥验证,返回false
Assert.assertFalse(verifySign("SHA256withECDSA", data, publicKey2, sign1));
}
我们在测试用例中可以看到,只要是数据、签名被修改,或者不正确的私钥都会引发签名验证失败.
3. 签名输出数据解析
针对 SHA256withECDSA ,输出的是DER编码的签名数据,长度并非固定,但是是70到72字节,之所以会这样是因为,SHA256withECDSA 签名输出实际是两个32字节的大整数(r和s),在转换成byte[]的时候,如果为负数,那么会添加前导位0,如果当r和s都是正数,那么就是64个字节,都是负数,就是66字节。而DER编码还会包含类型以及长度字段,因此总长度就会到70到72字节。一般情况下,传输DER编码的签名值没多大问题,但如果对数据量要求十分严格,例如在BLE上传输,可以提取出r和s再打包传输