椭圆曲线算法secp256k1进行数字签名 - Java

無名 发表于: 2018-07-24   最后更新时间: 2021-12-15 16:05:25  
{{totalSubscript}} 订阅, 11,601 游览

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[],然后使用:X509EncodedKeySpecPKCS8EncodedKeySpec 转换成公钥和私钥。

实例代码:

// 读取公钥, 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再打包传输

更新于 2021-12-15

查看java更多相关的文章或提一个关于java的问题,也可以与我们一起分享文章