MENU

【代码札记】X25519密钥交换算法的纯BouncyCastle实现

May 10, 2022 • Read: 62 • 瞎折腾

整个人类社会都在因为疫情开倒车:各国政府出于好意或恶意,无不以疫情为名试图拿走我们曾经有的权力。虽然我个人对此无能为力,但我认为,个体还是应当掌握技术以保护自己的。所以本文来介绍一下如何使用X25519密钥交换算法实现加密通信。

当然了,由于我并不是信安专业的,对于密码学这块并没有那么熟,所以这里只是简单的说一说原理。若是哪里说错了,还请读者友善的在评论区中提出。

加密的两大阵营

首先我们来说说加密算法的两大阵营吧,这是从密钥的结构来说的。对于我们常规使用的加密算法,例如打压缩包的时候,我们可以选择对压缩包加密。这种加密就是对称加密,因为加密解密使用同一个密钥,加密和解密互为逆过程,是对称的。这种加密算法的好处是速度比较快,但坏处是,如果你想把加密后的数据分享给别人,那么你就必须要将密码也一起分享给别人。如果途中这个密码泄露了,那么这个加密就形同虚设了。

在实践中,常用的加密算法有AES系列,ChaCha系列等,本文使用我较为习惯的AES-256-GCM。

而另一个阵营就是非对称加密,相比对称加密,这种加密算法的加密过程和解密过程是不同的。这种加密算法的钥匙有两种:私钥和公钥。它们成对出现,私钥是必须保护好的秘密数据,而公钥则由私钥计算得出,与私钥对应,但不会泄露私钥,因此可以随意公开出去。当别人想要给你发送数据时,对方首先需要使用你的公钥加密数据,而你接收到数据后,使用自己的私钥解密数据。这样一来,你的秘密私钥压根不需要发送出去,就大大减少了私钥泄露的可能性。但是非对称加密有一个问题:性能非常慢。

于是人们想出了一个好办法:先用非对称加密算法传输/协商一个比较短的、用于对称加密的密钥,再使用这个密钥进行对称加密。由于一开始的非对称加密不需要泄露私钥,是安全的,从而保证交换的加密密钥也是安全的,再使用这个密钥进行对称加密,通信的内容还是安全的,而且性能更高!

非对称加密的两大阵营

在说密钥交换之前,这里还想横插一杠,介绍一下非对称加密的两大主要阵营。它们分别是基于素数的RSA,和基于椭圆曲线的ECC。

RSA是一个非常老牌的非对称加密算法了,到现在(2022年)我的网站证书使用的仍然是RSA算法。RSA算法的基本原理就是利用大整数难以在多项式时间内被分解出因子来保证安全,因此,只要RSA密钥的长度足够长,别人就拿你没办法(目前推荐使用2048位以上的长度,我个人倾向于4096位)。并且在对抗量子攻击上(使用量子计算机破解密钥),RSA做得更好(可能是因为量子计算机也对着素数和分解因子发愁)。不过从另一个角度来说,现阶段而言,量子计算机只有国家或商业资助的大学和研究所才有,并且用起来也比较繁琐(IBM曾经提供过免费的量子计算试用,但我不会用)。国家或商业巨人来搞我,我配吗?我不配。所以抗量子攻击并不在我的主要威胁模型里。

而另一个阵营则是ECC,椭圆曲线加密算法。这种算法根据选择的曲线不同,各有各的特点。本文所使用的25519曲线,是由著名密码学家Daniel J. Bernstein设计的,据说设计的初衷是因为由美国国家机构设计的曲线在参数选取上不透明,可能存在后门,因此这位密码学家一气之下自己设计了一条公开透明而且还性能好的25519曲线。只不过这条曲线用起来比RSA稍微麻烦一些。对于加密和签名,RSA可以使用一套密钥,而25519则不行,基于25519的密钥交换算法称为X25519,而使用25519曲线的签名算法称为Ed25519,二者密钥长度相同,但要求却不同:Ed25519密钥来者不拒,只要够长就行,但X25519对密钥的特定位有要求,并且公钥的计算方式与Ed25519不同,使得二者无法相互替代。也就是说,如果同时需要加密和签名的话,使用25519曲线需要有两套私钥和公钥,但好在本文只用加密。

本文青睐ECC的另一个原因是,不考虑量子攻击的情况下,ECC的密钥长度通常比RSA的短,并能够提供相似的安全性。例如25519曲线使用256位的密钥(不可能更改)提供的安全强度(Security bits)接近128位(实际为126位),而要达到这种强度,RSA需要使用3072位;而448曲线使用448位密钥就可以提供224位的安全强度,而要想达到此强度,RSA的密钥长度需要达到一万多位(7680位RSA密钥的强度时192位,15360位RSA密钥的强度是256位)。因此当你想要交换公钥时,你肯定想要尽快结束这个过程,否则一旦被其他人注意到,交换过程就可能会受阻,因此考虑同等安全性,ECC的密钥更短,交换更快,更适合实际使用。

密钥交换算法

关于密钥交换算法,最简单的可以从Diffie-Hellman算法说起,该算法基于RSA密钥,利用素数的整数模n乘法群及其原根实现。所谓“整数模n乘法群”,就是一堆数(a,b,c,d...),他们与n互质,并且在模n后结果相同,这些数就是整数模n乘法群的成员。这里就不详细说明具体是怎么算的了,简单地说,可以通过互换RSA公钥实现“用自己的私钥和对方的公钥计算出相同的结果”。

还是以Alice和Bob为例,它们在安全的环境下(例如现实中阴暗无人的小巷)交换了双方的公钥,回家后,Alice使用自己的私钥和Bob的公钥,通过DH算法计算出了一个数,而Bob回家后用自己的私钥和Alice的公钥也计算出了一个数,DH算法保证他们俩算出来的数是相同的,又因为这个过程涉及到无人知晓的对方私钥,因此一旦公钥安全的交换了,Alice和Bob就具备了安全通信的能力(使用计算出来的数作为对称加密的密钥)。

如果把这种算法推广到椭圆曲线上,我们就得到了所谓的ECDH,椭圆曲线密钥交换算法。使用25519曲线的密钥交换算法称作X25519,这里我们使用Java的BouncyCastle库来实现。

实现

这里我们想做一个完整的程序,即从生成密钥到加密通信,步骤如下:

  • 双方生成私钥
  • 根据私钥计算X25519公钥
  • 双方交换X25519公钥
  • 计算公共秘密(Shared secret)
  • 使用这个公共密钥进行加密通信

使用BouncyCastle的话,一个缺点就是如果你想直接使用API,那文档就会比较少,使用JCE(Java加密扩展),你就必须忍受用字符串传参的丑陋代码,但文档确实多,而且好用。然而我比较固执,我宁愿自己研究,也要代码好看。所以本文使用纯BouncyCastle实现,除了java.security.SecureRandom之外就没有任何Java密码学的东西了。编程语言使用Kotlin,但是Java的话问题也不大,偏爱Kotlin就是因为语法糖非常舒服。

首先当然是定一个简单的框架:

  1. 使用org.bouncycastle.crypto.params.X25519PrivateKeyParameters生成X25519私钥并计算公钥,提取成字节数组方便保存、传输
  2. 使用org.bouncycastle.crypto.params.X25519PublicKeyParameters从字节数组中还原别人的公钥
  3. 使用org.bouncycastle.crypto.agreement.X25519Agreement进行X25519密钥交换
  4. 使用org.bouncycastle.crypto.modes.GCMSIVBlockCipherorg.bouncycastle.crypto.engines.AESEngine进行AES-256-GCM加密解密,其中使用某种方法产生不会重复的nonce作为AEAD的参数

生成私钥计算公钥当然好办:

    private val secureRandom = SecureRandom()

    private fun generatePrivateKey(): ByteArray =
        X25519PrivateKeyParameters(secureRandom).encoded

    private fun generatePublicKey(privateKeyBytes: ByteArray): ByteArray =
        X25519PrivateKeyParameters(privateKeyBytes).generatePublicKey().encoded

生成私钥后直接用getEncoded()把公钥编码成字节数组,公钥也是类似,由私钥计算出来后以字节数组的形式表示。有了公钥私钥,就可以考虑怎么协商X25519了:

    private fun doECDH(selfPrivateKeyBytes: ByteArray, remotePublicKeyBytes: ByteArray): ByteArray {
        val agreement = X25519Agreement()
        val result = ByteArray(agreement.agreementSize)
        agreement.init(X25519PrivateKeyParameters(selfPrivateKeyBytes))
        agreement.calculateAgreement(X25519PublicKeyParameters(remotePublicKeyBytes), result, 0)
        return result
    }

这里先用自己的私钥初始化X25519Agreement,然后使用别人的公钥计算协商的秘密,结果是32字节(256位),正好可以用来做AES 256位加密:

    private fun encryptMessage(key: ByteArray, message: ByteArray): Pair<ByteArray, ByteArray> {
        val cipher = GCMSIVBlockCipher(AESEngine())
        // mac size is ranged from 32~128 bits, for message integrity check
        // nonce can be any size, but must not be reused
        // the SIV will have more security when nonce is reused
        // but the max message it can handle is 2^31 - 24 bytes
        val nonce = ByteArray(12)
        secureRandom.nextBytes(nonce)
        cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce))
        val result = ByteArray(cipher.getOutputSize(message.size))
        val resultSize = cipher.processBytes(message, 0, message.size, result, 0)
        cipher.doFinal(result, resultSize)
        return result to nonce
    }

这里选择的加密方式是AES-256-GCM,因为GCM是一个AEAD加密,而AEAD可以同时用于加密和完整性验证。换言之如果消息在传输过程中被篡改,这种加密可以探测到这种篡改。这种加密方式有三个参数:

  • 加密用的密钥,key
  • MAC(Message Authentication Code)大小,一般取最大值128,从而尽可能检测消息被篡改
  • nonce,由于GCM是一种计数器模式的加密算法,因此需要一个值(偏移量)来确保同一个密钥在加密不同块时有所变化。重复使用nonce可能会导致算法的安全性受到破坏

为了减小重复使用nonce带来的安全隐患,这里我使用了GCM-SIV变体,该变体在nonce被重用时提供了更好的抵抗力,但代价是BouncyCastle的GCM-SIV实现最大只能支持$2^{31}-24$字节,如果超出这个大小,则必须分多次加密,但好在这个就是个演示,单次发送并不会有那么大的数据量,有的话,限制一下就是了。

接下来是解密:

    private fun decryptMessage(key: ByteArray, encryptedMessage: ByteArray, nonce: ByteArray): ByteArray {
        val cipher = GCMSIVBlockCipher(AESEngine())
        cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce))
        val result = ByteArray(cipher.getOutputSize(encryptedMessage.size))
        val resultSize = cipher.processBytes(encryptedMessage, 0, encryptedMessage.size, result, 0)
        cipher.doFinal(result, resultSize)
        return result
    }

最后方便打印字节数组,我们把它用Hex表示出来:

    private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }

最后的最后,我们把这些功能串起来做一个演示:

    @JvmStatic
    fun main(args: Array<String>) {
        val serverPrivateKeyBytes = generatePrivateKey()
        println("Server x25519 private key: " + serverPrivateKeyBytes.toHex())
        val clientPrivateKeyBytes = generatePrivateKey()
        println("Client x25519 private key: " + clientPrivateKeyBytes.toHex())

        // test key exchange
        val serverECDHPublicKey = generatePublicKey(serverPrivateKeyBytes)
        println("Server x25519 public key: " + serverECDHPublicKey.toHex())
        val clientECDHPublicKey = generatePublicKey(clientPrivateKeyBytes)
        println("Client x25519 public key: " + clientECDHPublicKey.toHex())

        val serverECDHResult = doECDH(serverPrivateKeyBytes, clientECDHPublicKey)
        println("Server x25519 result: " + serverECDHResult.toHex())
        val clientECDHResult = doECDH(clientPrivateKeyBytes, serverECDHPublicKey)
        println("Client x25519 result: " + clientECDHResult.toHex())

        require(serverECDHResult.contentEquals(clientECDHResult))

        // test encryption
        val message = "This is a message. 中文消息。".encodeToByteArray()
        println("Message: " + message.toHex())
        val (serverSendEncryptedMessage, serverSendNonce) = encryptMessage(serverECDHResult, message)
        println("Server sent message: " + serverSendEncryptedMessage.toHex())
        println("Server sent nonce:   " + serverSendNonce.toHex())
        val clientDecrypt = decryptMessage(clientECDHResult, serverSendEncryptedMessage, serverSendNonce)
        println("Client decrypt: " + clientDecrypt.toHex())

        require(message.contentEquals(clientDecrypt))
    }

输出如下:

Server x25519 private key: f068efa402d7e8b539ad7964c5f065cd0310f48b49716580c6d4201e3ed6bc45
Client x25519 private key: 9035a47bf3b071ff26519a1dfe48a2b0bd61fdea625e8c02069422bfc1c3bc75
Server x25519 public key: 68466086a2c7481f084e138ea6e6310c19b9bcf39b0362443e0f91ed6123996f
Client x25519 public key: d13382775b7288e4bbddac313237231eeacead8abaa16efcbf49f8d332d27c48
Server x25519 result: b88bb584f2970e04369dbfc8f745cf8d0c0631684ef237a4b32131ccb061e65c
Client x25519 result: b88bb584f2970e04369dbfc8f745cf8d0c0631684ef237a4b32131ccb061e65c
Message: 546869732069732061206d6573736167652e20e4b8ade69687e6b688e681afe38082
Server sent message: f54f6f67fac24535bcf8da3b0a61997375e49ba444ffe5cf0b3bb2a3d6a46870a9072214b178e298be94cb825ad0d22f0f36
Server sent nonce:   6fe6165722959386b03885e6
Client decrypt: 546869732069732061206d6573736167652e20e4b8ade69687e6b688e681afe38082

可以看到,服务器和客户端只用自己的私钥和对方的公钥就实现了X25519协商,并以此进行对称加密的通信。

完整代码

package info.skyblond.crypto

import org.bouncycastle.crypto.agreement.X25519Agreement
import org.bouncycastle.crypto.engines.AESEngine
import org.bouncycastle.crypto.modes.GCMSIVBlockCipher
import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters
import org.bouncycastle.crypto.params.X25519PublicKeyParameters
import java.security.SecureRandom

/**
 * This class demonstrate how to perform a X25519 key exchange with pure BouncyCastle.
 * */
object X25519WithPureBC {
    private val secureRandom = SecureRandom()

    /**
     * Generate private key depends on curve.
     * */
    private fun generatePrivateKey(): ByteArray =
        X25519PrivateKeyParameters(secureRandom).encoded

    /**
     * Generate the public key for ECDH key exchange.
     * */
    private fun generatePublicKey(privateKeyBytes: ByteArray): ByteArray =
        X25519PrivateKeyParameters(privateKeyBytes).generatePublicKey().encoded

    private fun doECDH(selfPrivateKeyBytes: ByteArray, remotePublicKeyBytes: ByteArray): ByteArray {
        val agreement = X25519Agreement()
        val result = ByteArray(agreement.agreementSize)
        agreement.init(X25519PrivateKeyParameters(selfPrivateKeyBytes))
        agreement.calculateAgreement(X25519PublicKeyParameters(remotePublicKeyBytes), result, 0)
        return result
    }

    /**
     * Return: (Encrypted message, nonce)
     * */
    private fun encryptMessage(key: ByteArray, message: ByteArray): Pair<ByteArray, ByteArray> {
        val cipher = GCMSIVBlockCipher(AESEngine())
        // mac size is ranged from 32~128 bits, for message integrity check
        // nonce can be any size, but must not be reused
        // the SIV will have more security when nonce is reused
        // but the max message it can handle is 2^31 - 24 bytes
        val nonce = ByteArray(12)
        secureRandom.nextBytes(nonce)
        cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce))
        val result = ByteArray(cipher.getOutputSize(message.size))
        val resultSize = cipher.processBytes(message, 0, message.size, result, 0)
        cipher.doFinal(result, resultSize)
        return result to nonce
    }

    private fun decryptMessage(key: ByteArray, encryptedMessage: ByteArray, nonce: ByteArray): ByteArray {
        val cipher = GCMSIVBlockCipher(AESEngine())
        cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce))
        val result = ByteArray(cipher.getOutputSize(encryptedMessage.size))
        val resultSize = cipher.processBytes(encryptedMessage, 0, encryptedMessage.size, result, 0)
        cipher.doFinal(result, resultSize)
        return result
    }

    private fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }

    @JvmStatic
    fun main(args: Array<String>) {
        val serverPrivateKeyBytes = generatePrivateKey()
        println("Server x25519 private key: " + serverPrivateKeyBytes.toHex())
        val clientPrivateKeyBytes = generatePrivateKey()
        println("Client x25519 private key: " + clientPrivateKeyBytes.toHex())

        // test key exchange
        val serverECDHPublicKey = generatePublicKey(serverPrivateKeyBytes)
        println("Server x25519 public key: " + serverECDHPublicKey.toHex())
        val clientECDHPublicKey = generatePublicKey(clientPrivateKeyBytes)
        println("Client x25519 public key: " + clientECDHPublicKey.toHex())

        val serverECDHResult = doECDH(serverPrivateKeyBytes, clientECDHPublicKey)
        println("Server x25519 result: " + serverECDHResult.toHex())
        val clientECDHResult = doECDH(clientPrivateKeyBytes, serverECDHPublicKey)
        println("Client x25519 result: " + clientECDHResult.toHex())

        require(serverECDHResult.contentEquals(clientECDHResult))

        // test encryption
        val message = "This is a message. 中文消息。".encodeToByteArray()
        println("Message: " + message.toHex())
        val (serverSendEncryptedMessage, serverSendNonce) = encryptMessage(serverECDHResult, message)
        println("Server sent message: " + serverSendEncryptedMessage.toHex())
        println("Server sent nonce:   " + serverSendNonce.toHex())
        val clientDecrypt = decryptMessage(clientECDHResult, serverSendEncryptedMessage, serverSendNonce)
        println("Client decrypt: " + clientDecrypt.toHex())

        require(message.contentEquals(clientDecrypt))
    }
}

-全文完-


知识共享许可协议
【代码札记】X25519密钥交换算法的纯BouncyCastle实现天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://www.skyblond.info/about.html 处获得。

Archives QR Code Tip
QR Code for this page
Tipping QR Code