C# 和 Kotlin ECDH 共享密钥不匹配。

3

使用以下方法派生共享密钥:

在曲线“secp384r1”下会产生不同的结果。

Kotlin 相关链接指向 Kotlin for Android 文档以提高可读性。

简化的驱动程序代码,用于演示问题,假设 C# .NET 7.0.1 控制台应用程序是“服务器”,而 Kotlin OpenJDK 19.0.1 应用程���是“客户端”:

C#:

using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;

var listener = new TcpListener(IPAddress.Any, 13000);
listener.Start();
using var client = await listener.AcceptTcpClientAsync();
var sharedKey = await GetSharedKey(client, CancellationToken.None);

async Task<byte[]> GetSharedKey(TcpClient client, CancellationToken token)
{
    //Generate ECDH key pair using secp384r1 curve
    var ecdh = new ECDiffieHellmanCng(ECCurve.CreateFromFriendlyName("secp384r1"));
    var publicKeyBytes = ecdh.ExportSubjectPublicKeyInfo();
    Console.WriteLine($"Server Public Key: {Convert.ToBase64String(publicKeyBytes)}, " +
                      $"Length: {publicKeyBytes.Length}");

    //Send the generated public key encoded in X.509 to client.
    var stream = client.GetStream();
    await stream.WriteAsync(publicKeyBytes, token);
        
    //Receive client's public key bytes (X.509 encoding).
    var otherPublicKeyBytes = new byte[publicKeyBytes.Length];
    await stream.ReadExactlyAsync(otherPublicKeyBytes, 0, otherPublicKeyBytes.Length, token);
        
    //Decode client's public key bytes.
    var otherEcdh = new ECDiffieHellmanCng(ECCurve.CreateFromFriendlyName("secp384r1"));
    otherEcdh.ImportSubjectPublicKeyInfo(otherPublicKeyBytes, out _);
    Console.WriteLine($"Client Public Key: {Convert.ToBase64String(otherEcdh.ExportSubjectPublicKeyInfo())}, " +
                      $"Length: {otherEcdh.ExportSubjectPublicKeyInfo().Length}");

    //Derive shared key.
    var sharedKey = ecdh.DeriveKeyMaterial(otherEcdh.PublicKey);
    Console.WriteLine($"Shared key: {Convert.ToBase64String(sharedKey)}, " +
                      $"Length: {sharedKey.Length}");
    return sharedKey;
}

Kotlin:

import java.net.Socket
import java.security.KeyFactory
import java.security.KeyPairGenerator
import java.security.spec.ECGenParameterSpec
import java.security.spec.X509EncodedKeySpec
import java.util.*
import javax.crypto.KeyAgreement

fun main(args: Array<String>) {
    val socket = Socket("127.0.0.1", 13000)
    val sharedKey = getSharedKey(socket)
}

private fun getSharedKey(socket: Socket): ByteArray {
    //Generate ECDH key pair using secp384r1 curve
    val keyGen = KeyPairGenerator.getInstance("EC")
    keyGen.initialize(ECGenParameterSpec("secp384r1"))
    val keyPair = keyGen.generateKeyPair()
    println("Client Public Key: ${Base64.getEncoder().encodeToString(keyPair.public.encoded)}, Length: ${keyPair.public.encoded.size}")

    //Receive server's public key bytes (encoded in X.509)
    val input = socket.getInputStream()
    val publicKeyBytes = input.readNBytes(keyPair.public.encoded.size)

    //Send the generated public key encoded in X.509 to server
    val output = socket.getOutputStream()
    output.write(keyPair.public.encoded)

    // Decode the server's public key
    val keySpec = X509EncodedKeySpec(publicKeyBytes)
    val keyFactory = KeyFactory.getInstance("EC")
    val otherPublicKey = keyFactory.generatePublic(keySpec)
    println("Server Public Key: ${Base64.getEncoder().encodeToString(otherPublicKey.encoded)}, Length: ${otherPublicKey.encoded.size}")

    // Use KeyAgreement to generate the shared key
    val keyAgreement = KeyAgreement.getInstance("ECDH")
    keyAgreement.init(keyPair.private)
    keyAgreement.doPhase(otherPublicKey, true)
    val sharedKey = keyAgreement.generateSecret()
    println("Shared key: ${Base64.getEncoder().encodeToString(sharedKey)}, Length: ${sharedKey.size}")
    return sharedKey
}

C# 输出:

Server Public Key: MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqza/eiK23hQIEW5mVdqOc0hAP3tPqittlcvPa6bGdyJK9n64sg0qYyDoPsxJ4pf7ROLz0ACrDS7n/e5Z0J1SMsWpBDViS8NRBvKwa1rQjWdFR0wzRaeVg09LIjnGs4Mj, Length: 120
Client Public Key: MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE30zvqkljT4STiE6XfLtoN147WRGA92rz9BLZfbRkOjz7uNbQ3az46DdoyQi6+eON7QVjIf2H5LKBANSk+C5zRX6u8jjrbhURDHYBKgijOddy6mOaEwiADijD/NX72O2L, Length: 120
Shared key: /u+tZYHar4MxXfrn2oqPZAqhiB2pkSTRBZ12rUxdnII=, Length: 32

Kotlin 输出:

Client Public Key: MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE30zvqkljT4STiE6XfLtoN147WRGA92rz9BLZfbRkOjz7uNbQ3az46DdoyQi6+eON7QVjIf2H5LKBANSk+C5zRX6u8jjrbhURDHYBKgijOddy6mOaEwiADijD/NX72O2L, Length: 120
Server Public Key: MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEqza/eiK23hQIEW5mVdqOc0hAP3tPqittlcvPa6bGdyJK9n64sg0qYyDoPsxJ4pf7ROLz0ACrDS7n/e5Z0J1SMsWpBDViS8NRBvKwa1rQjWdFR0wzRaeVg09LIjnGs4Mj, Length: 120
Shared key: lErK9DJAutaJ4af7EYWvtEXicAwfSuadtQhlZxug26wGkgB/ce7hF6ihLL87Sqc3, Length: 48

公钥的导入/导出似乎没有问题,但是C#端无法生成正确长度的密钥(384 / 8 = 48)。

编辑: 有人注意到,有趣的是,C#的“共享密钥”实际上是Kotlin的共享密钥的SHA256哈希值,而不是实际密钥。

我强烈怀疑这是因为默认密钥派生函数不匹配,但并不完全确定。

我想知道我做错了什么以及如何解决这个问题。

编辑#2 - 解决方案: 正如接受的答案所建议的那样-我的怀疑并不完全错误。 ECDiffieHellmanCng.DeriveKeyMaterial多做了一些不必要的工作-即默认返回派生密钥的SHA256哈希值而不是实际密钥,并且不提供任何返回实际密钥的方法。

对于任何对获取48字节共享密钥感兴趣的人,您只能得到其SHA384(或其他哈希算法)散列值(或使用BouncyCastle):

C#更改:

//Generate ECDH key pair using secp384r1 curve and change default key's hashing algorithm SHA256 to SHA384
var ecdh = new ECDiffieHellmanCng(ECCurve.CreateFromFriendlyName("secp384r1"))
{
    HashAlgorithm = CngAlgorithm.Sha384
};

Kotlin 变更:

val sharedKey = keyAgreement.generateSecret()
val sharedKeyHash = MessageDigest.getInstance("SHA384").digest(sharedKey)
println("Shared key SHA384 hash: ${Base64.getEncoder().encodeToString(sharedKeyHash)}, Length: ${sharedKeyHash.size}")
return sharedKeyHash

我建议将GetSharedKey方法重命名为其实际名称-GetSharedKeysSHA384Hash。


测试过了,你是对的,@Topaco。有没有什么办法可以获取实际密钥而不是它的哈希值? - Sorhox
在之前的版本中这是不可能的(请参见此处),是否在.NET 6+中可行我不确定。 - Topaco
我在.NET 7中没有看到任何区别... - Maarten Bodewes
1
ECDiffieHellman.DeriveKeyMaterial() 的默认行为在 .NET 文档中有描述,参见 Remarks,即使用 SHA256 进行哈希。这里 是一个帖子,其中包含一个代码片段,用于计算 .NET 的 原始 共享密钥(如有需要,建议使用 BC)。 - Topaco
2个回答

2
问题在于C#做的比类所期望的更多。即通常情况下,.NET库不遵循最小惊奇原则:
ECDiffieHellmanCng类使两个参与方能够交换私钥材料,即使他们通过公共信道进行通信。双方都可以计算相同的秘密值,这被称为托管Diffie-Hellman类中的秘密协议。然后可以将秘密协议用于各种目的,包括作为对称密钥。但是,ECDiffieHellmanCng类在提供该值之前对协议进行了一些后处理。此后处理称为密钥派生函数(KDF);您可以通过一组属性在Diffie-Hellman对象的实例上选择要使用的KDF并设置其参数。
当然,这段代码的开发者并没有明确指定他们在KDF上执行的操作,也没有指定从所显示的选项中使用的默认方法。然而,可以预期他们会在Diffie-Hellman密钥协商计算出的X坐标上执行该操作。
话虽如此,从Java类描述中也不太清楚。standard names文档引用了RFC 3278,该文档指向了旧的Sec1标准第6.1节,但链接已经失效。现在仍然可以下载Sec1,并且如果我们查看第6.1节,我们会找到一种将整数编码为字段大小的字节数(然后取所需字节)的构造方法。然而,无疑返回的是与Microsoft使用的KDF的输入密钥材料相同的编码X坐标。

哇,那是一堆话,意思就是你必须把Kotlin代码的结果以字节形式取出来,然后对其执行SHA-256算法。噢,对了,SHA-256默认值被猜测了,据我所见Microsoft也没有指定它,虽然他们公开了KeyDerivationFunctionHashAlgorithm属性并为它们定义了默认值。

有一些选项可以选择各种KDF函数的参数,对于ECDiffieHellmanCng,但如果你想要“原始”的X坐标,似乎运气不太好。如果你需要它,你可能需要使用Bouncy Castle for C#,但要注意它返回的是X坐标的原始整数,而不是一个 静态大小、无符号、大端整数的编码。


如果您需要创建X坐标的正确编码代码,请告诉我。请注意,C#的ToUnsignedByteArray可能太小了,而且我也不确定字节序。 - Maarten Bodewes
谢谢@MaartenBodewes,但不用了,目的是在两个应用程序之间安全地拥有相同的48字节数组,为此需要做的所有额外工作就是简单地覆盖C#以使用SHA384并在Kotlin中获取共享密钥的SHA384哈希。 - Sorhox
1
很幸运,您发现了一种更加安全的选项(HMAC甚至更强)。ECDH产生的X坐标相当随意,但绝对不是完全随机的,这就是为什么您实际上需要使用KDF(直接使用X坐标并没有被破解,但是确实存在问题)。问题在于有许多许多略有不同的KDF,因此在互操作性方面,具有原始输出可以帮助。我不会让您感到无聊,如果您需要将秘密存储在硬件安全模块中,需要注意的是什么。买同样的HSM即可。 - Maarten Bodewes

0
我们最近遇到了这个问题,当我们不受控制的某个接口要求我们使用基于未经哈希处理的原始密钥的密钥,其长度为给定长度(例如32个字节),需要填充或修剪一些0x00,但在没有原始密钥的情况下是不可能的。
据我所知,他们正在尝试在.NET 8中实现它,因此,如果您可以选择环境,您可以在给定时间放弃BC... https://github.com/dotnet/runtime/issues/71613

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接