整个人类社会都在因为疫情开倒车:各国政府出于好意或恶意,无不以疫情为名试图拿走我们曾经有的权力。虽然我个人对此无能为力,但我认为,个体还是应当掌握技术以保护自己的。所以本文来介绍一下如何使用 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 就是因为语法糖非常舒服。
首先当然是定一个简单的框架:
- 使用
org.bouncycastle.crypto.params.X25519PrivateKeyParameters
生成 X25519 私钥并计算公钥,提取成字节数组方便保存、传输 - 使用
org.bouncycastle.crypto.params.X25519PublicKeyParameters
从字节数组中还原别人的公钥 - 使用
org.bouncycastle.crypto.agreement.X25519Agreement
进行 X25519 密钥交换 - 使用
org.bouncycastle.crypto.modes.GCMSIVBlockCipher
和org.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://skyblond.info/about.html 处获得。