美文网首页密码学知识Android开发Android开发
Java密码学 非对称加密以及使用secp256k1进行数字签名

Java密码学 非对称加密以及使用secp256k1进行数字签名

作者: 夏日里的故事 | 来源:发表于2018-01-27 16:16 被阅读1660次

    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);
        }
    

    4. 小结

    在大部分系统业务系统里面,频繁生成、加载秘钥的业务是不多的,但是如果做一个开放性API体系,可能用的就比较多了(例如微信、支付宝一些业务接入就需要提供公钥),而且秘钥来源软件比较多,这里可能需要深入了解:PKCS系列标准、X.509等。大家可以自行搜索相关内容。

    相关文章

      网友评论

        本文标题:Java密码学 非对称加密以及使用secp256k1进行数字签名

        本文链接:https://www.haomeiwen.com/subject/rrdgoxtx.html