はじめに
平文通信はRS-232Cまでだよねーキャハハって言われた気がしたので今時っぽく暗号化する話
で、RSA鍵交換ももう使われていないわけなので、ECDHを説明してみようということです
概要
通信を暗号化ということで、AESのような共通鍵暗号が基本
問題は鍵をどうやってお互いに持つのかということ
安全のためには「保存されない一時的な鍵」を使うときに生成、通信経路を通さずに相手と共有したいわけ
そこで出てくるのが鍵交換、今はECDHが多いので、とりあえずこれでやってみる
鍵ペア(公開鍵と秘密鍵のペア)をお互いに生成して、公開鍵を交換、自分の秘密鍵と相手の公開鍵から同じ値が作れるというところがミソ
手順
自分と相手で暗号化通信するとする
-
お互いに鍵ペア(公開鍵と秘密鍵)を作り、公開鍵をお互いに送り合う(ここは平文)
名前の通り、公開鍵は公開して構わないので平文でOK、また、この鍵ペアは上述の通り一時的なもので、基本的にはセッションが続く間だけ使われる -
salt を共有する
saltは見られても良いので、これまた平文
ランダムなバイト列を生成してどちらかから送れば良いが、両方で生成したものを送り合って繋げて使ったりする -
相手の公開鍵と自分の秘密鍵から共通シークレットを導出する
これが一見不思議なんだけど、自分の秘密鍵と相手の公開鍵をゴニョゴニョすると、お互いに同じランダムっぽいバイト列が得られるので、これを鍵にする(もちろん、両方とも自分の秘密鍵と相手の公開鍵なので別物) -
共通シークレットをさらにゴニョゴニョして共通鍵を作る
共通シークレットから、さらにランダムな感じにするのがHKDF(HMAC-based Extract-and-Expand Key Derivation Function)
数値の出現頻度がより平均化され、ランダムなバイト列に近づくらしい
共通シークレットと、2で共有したsaltから作る -
これでお互いに同じランダムな感じの鍵が得られるので、後はAESで暗号化して通信すればOK
実際のコード
自分がJava、相手がC#でやろうとして異常に苦労したので、とりあえず記録しておくということで
なんか公開鍵を送り合うところとか、自動的にハッシュにするとかで共通シークレットが一致せず、2時間ほどChat-GPT先生を問い詰めた後、Gemini先生に相談してやっとできた
ちなみにC#は .NET Framework だと機能が足りない(外部ライブラリ使えばいけるっぽいけど)
.NET 8なら大丈夫ということで、ここでは .NET 8を使っている
まあサポートのこともあるし、8が良いでしょう、多分
鍵ペアの生成
一時的に使うので、ランダムな鍵ペアを作る
まあここは問題ないかと
Java
public static KeyPair generateEcKeyPair()
{
try {
ECGenParameterSpec ecspec = new ECGenParameterSpec("secp256r1");
KeyPairGenerator keygen = KeyPairGenerator.getInstance("EC");
keygen.initialize(ecspec);
return keygen.generateKeyPair();
}
catch (Exception e) {
Log.write("generateKeyPair exception: " + e.toString());
return null;
}
}
C#
public static ECDiffieHellman GenerateECKeyPair()
{
return ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
}
鍵ペアからバイト列へ
ここではまっていた
Gemini先生の教えで、圧縮されていない X,Y そのままの形式にしてやり取りすることでとりあえずできている
java 同士なら getEncoded()、C# 同士なら ExportSubjectPublicKeyInfo() でもできると思う(Java はできた、C# は未確認)
Java
private static final int keyLength = 32;
public static byte[] getUncompressedKeyBytes(PublicKey pubkey)
{
ECPublicKey eckey = (ECPublicKey)pubkey;
ECPoint point = eckey.getW();
BigInteger x = point.getAffineX();
BigInteger y = point.getAffineY();
byte[] x_bytes = toFixedLengthBytes(x, keyLength);
byte[] y_bytes = toFixedLengthBytes(y, keyLength);
ByteBuffer buf = ByteBuffer.allocate(keyLength * 2 + 1);
buf.put((byte)0x04); // データタイプは 0x04
buf.put(x_bytes);
buf.put(y_bytes);
return buf.array();
}
private static byte[] toFixedLengthBytes(BigInteger bi, int len)
{
byte[] b = bi.toByteArray();
if (b.length == len) {
return b;
}
if (len < b.length) {
// 元々32bytesのはずだが、符号バイトで33bytesになる可能性あり、この場合は符号バイトだけ削除する
return Arrays.copyOfRange(b, 1, b.length);
}
byte[] padded = new byte[len];
Arrays.fill(padded, (byte)0);
System.arraycopy(b, 0, padded, len - b.length, b.length);
return padded;
}
C#
public static ECParameters? GetEcParametersFromUncompressed(byte[] keyBytes)
{
if (0x04 != keyBytes[0] || keyBytes.Length != (1 + KEY_LENGTH * 2))
{
return null;
}
byte[] xBytes = new byte[KEY_LENGTH];
Buffer.BlockCopy(keyBytes, 1, xBytes, 0, KEY_LENGTH);
byte[] yBytes = new byte[KEY_LENGTH];
Buffer.BlockCopy(keyBytes, 1 + KEY_LENGTH, yBytes, 0, KEY_LENGTH);
ECParameters ret = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint
{
X = xBytes,
Y = yBytes
}
};
return ret;
}
これでできたバイト列をお互いに送り合う
バイト列から公開鍵へ
バイト列にした時の形式が違うので、逆にバイト列から作る時もX, Yを取り出してなんとかする
引数は受信したバイト列そのままでいけるはず
getEncoded でバイト列にした場合はこんなことしなくても X509EncodedKeySpec をバイト列から作って公開鍵が作れるはず
Java
public static PublicKey createPublicKeyFromUncompressed(byte[] key_bytes)
{
try {
if (0x04 != key_bytes[0] || key_bytes.length != (keyLength * 2 + 1)) {
Log.write("createPublicKeyFromUncompressed: invalid format");
return null;
}
byte[] x_bytes = new byte[keyLength];
System.arraycopy(key_bytes, 1, x_bytes, 0, keyLength);
BigInteger x = new BigInteger(1, x_bytes);
byte[] y_bytes = new byte[keyLength];
System.arraycopy(key_bytes, keyLength + 1, y_bytes, 0, keyLength);
BigInteger y = new BigInteger(1, y_bytes);
ECGenParameterSpec ecspec = new ECGenParameterSpec("secp256r1");
KeyPairGenerator key_gen = KeyPairGenerator.getInstance("EC");
key_gen.initialize(ecspec);
ECPublicKeySpec kspec = new ECPublicKeySpec(new ECPoint(x, y), ((ECPublicKey)key_gen.generateKeyPair().getPublic()).getParams());
KeyFactory kf = KeyFactory.getInstance("EC");
return kf.generatePublic(kspec);
}
catch (Exception e) {
Log.write("createPublicKeyFromUncompressed exception: " + e.toString());
return null;
}
}
C#
public static ECParameters? GetEcParametersFromUncompressed(byte[] keyBytes)
{
if (0x04 != keyBytes[0] || keyBytes.Length != (1 + KEY_LENGTH * 2))
{
return null;
}
byte[] xBytes = new byte[KEY_LENGTH];
Buffer.BlockCopy(keyBytes, 1, xBytes, 0, KEY_LENGTH);
byte[] yBytes = new byte[KEY_LENGTH];
Buffer.BlockCopy(keyBytes, 1 + KEY_LENGTH, yBytes, 0, KEY_LENGTH);
ECParameters ret = new ECParameters
{
Curve = ECCurve.NamedCurves.nistP256,
Q = new ECPoint
{
X = xBytes,
Y = yBytes
}
};
return ret;
}
salt を生成する
まあ、ランダムなバイト列なだけなんだけど、両方で同じ値を使う必要がある
ChatGPT先生は双方から32バイトずつ生成してお互いに送信、繋げて使っていた
Java
public static byte[] generateSalt(int len)
{
byte[] b = new byte[len];
new SecureRandom().nextBytes(b);
return b;
}
C#
public static byte[] GenerateSalt(int len)
{
byte[] ret = new byte[len];
RandomNumberGenerator.Fill(ret);
return ret;
}
秘密鍵と相手の公開鍵から共通シークレット導出
ここでもハッシュ化されていたりしてはまっていた
C# の方はDeriveKeyMaterial でも行けそうな気がするけど、とりあえずGemini先生の教え通りのコード
Java
public static byte[] deriveSharedSecret(PrivateKey prikey, PublicKey pubkey)
{
try {
KeyAgreement ka = KeyAgreement.getInstance("ECDH");
ka.init(prikey);
ka.doPhase(pubkey, true);
// .NET ではハッシュになっているので、ここでハッシュにする
return calcHash(ka.generateSecret());
}
catch (Exception e) {
Log.write("generateSharedSecret exception: " + e.toString());
return null;
}
}
private static byte[] calcHash(byte[] src)
{
try {
return MessageDigest.getInstance("SHA-256").digest(src);
}
catch (Exception e) {
Log.write("calcHash exception: " + e.toString());
return null;
}
}
C#
public static byte[] DeriveSharedSecret(ECDiffieHellman prikey, ECParameters pubkeyParam)
{
using var peerKey = ECDiffieHellman.Create(pubkeyParam);
return prikey.DeriveKeyFromHash(peerKey.PublicKey, HashAlgorithmName.SHA256);
}
HKDF
ここまででお互いしか知らないバイト列を共有できたけど、そこから更にランダム性を高めてAESの鍵とする
共通シークレット、salt、info(お互いに既知なデータ、ここでは固定の文字列を使っている)から生成する
Java
public static byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int len)
{
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec ks = new SecretKeySpec(salt, "HmacSHA256");
mac.init(ks);
byte[] prk = mac.doFinal(ikm);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(info);
mac.update((byte)1);
return Arrays.copyOf(mac.doFinal(), len);
}
catch (Exception e) {
Log.write("hkdf exception: " + e.toString());
return null;
}
}
C#
public static byte[]? Hkdf(byte[] ikm, byte[] salt, byte[] info, int len)
{
try
{
// ステップ1: PRK = HMAC(salt, ikm)
byte[] prk;
using (var hmac = new HMACSHA256(salt))
{
prk = hmac.ComputeHash(ikm);
}
// ステップ2: T(1) = HMAC(PRK, info | 0x01)
byte[] t1;
using (var hmac = new HMACSHA256(prk))
{
hmac.TransformBlock(info, 0, info.Length, null, 0);
hmac.TransformFinalBlock(new byte[] { 0x01 }, 0, 1);
if (null == hmac.Hash)
{
return null;
}
t1 = hmac.Hash;
}
// len が t1 の長さより短い場合に備えて切り詰める
if (len < t1.Length)
{
byte[] result = new byte[len];
Array.Copy(t1, result, len);
return result;
}
else
{
return t1;
}
}
catch (Exception e)
{
Log.Write("Hkdf exception: " + e.ToString());
return null;
}
}
これでAESの鍵ができたので、後はそれで暗号化通信しましょうということで
呼び出す側コード
ここまで紹介したコードを呼び出して鍵交換する部分のコード
とりあえずJavaが先に送っているけど、別にきまりがあるわけでは無い、とにかくお互いに共有できればOK
※ sendBytes, receiveBytes はその名の通りバイト列を送受信するだけ
※ hkdfに渡している "aes-key".getBytes は両方で同じものならば何でもよい
Java
private static final int aesKeyLength = 32;
public static byte[] keyExchange()
{
// 公開鍵を送って受信
// まずは一時的な鍵ペアの生成
KeyPair ephemeralKey = generateEcKeyPair();
// 公開鍵をバイト列に変換して送信
sendBytes(getUncompressedKeyBytes(ephemeralKey.getPublic()));
// 相手の公開鍵を受信(バイト列で受信して PublicKey を生成)
byte[] peerPubKeyBytes = receiveBytes();
PublicKey peerPubKey = createPublicKeyFromUncompressed(peerPubKeyBytes);
// salt を送ってから受信
byte[] mySalt = generateSalt(32);
sendBytes(mySalt);
byte[] peerSalt = receiveBytes();
// お互いのsaltを繋げて、実際に使うsaltを生成
byte[] salt = concatBuffer(mySalt, peerSalt);
// 共有シークレットを導出
byte[] sharedSecret = deriveSharedSecret(ephemeralKey.getPrivate(), peerPubKey);
// HKDF で AES 鍵生成 (AES 鍵は32bytes = 256bit)
byte[] aesKey = hkdf(sharedSecret, salt, "aes-key".getBytes(StandardCharsets.US_ASCII), aesKeyLength);
// これで完了
return aesKey;
}
// 2つの byte[] を連結
// なんか分かりやすくてお気に入りなので ByteArrayOutputStreamを使っているけど、多分 System.arraycopy とかの方が速い
private static byte[] concatBuffer(byte[] src1, byte[] src2)
{
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream(src1.length + src2.length);
bos.write(src1);
bos.write(src2);
return bos.toByteArray();
}
catch (Exception e) {
Log.write("concatBuffer exception: " + e.toString());
return null;
}
}
C#
public static byte[]? KeyExchange()
{
// 公開鍵
// こっちは先に受信
byte[]? peerPubKeyBytes = ReceiveBytes();
if (null == peerPubKeyBytes)
{
Log.Write("public key receive error 1");
return null;
}
ECParameters? peerPubKeyParamTemp = GetEcParametersFromUncompressed(peerPubKeyBytes);
if (null == peerPubKeyParamTemp) {
Log.Write("public key receive error 2");
return null;
}
ECParameters peerPubKeyParam = (ECParameters)peerPubKeyParamTemp;
// 次に送信
var keyPair = GenerateECKeyPair();
SendBytes(GetUncompressedKeyBytes(keyPair));
// salt
// これも先に受信
byte[]? peerSalt = ReceiveBytes();
if (null == peerSalt)
{
Log.Write("salt receive error");
return null;
}
byte[] mySalt = GenerateSalt(32);
// あたりまえだけどJava側と同じ順番に繋げるので、こっちでは自分が生成したのが後
byte[] salt = ConcatBuffer(peerSalt, mySalt);
// 次に送信
SendBytes(mySalt);
// 共有シークレット
byte[] sharedSecret = DeriveSharedSecret(keyPair, peerPubKeyParam);
// AES 鍵
byte[]? aesKey = Hkdf(sharedSecret, salt, Encoding.ASCII.GetBytes("aes-key"), AES_KEY_LENGTH);
return aesKey;
}
private static byte[] ConcatBuffer(byte[] src1, byte[] src2)
{
return src1.Concat(src2).ToArray();
}