C#で楕円曲線暗号を使いたい②

ECDHで共有秘密を作ってみる

前回、BouncyCastleでECIESを使おうとして、NotImplementedException にぶつかりました。

「楕円曲線暗号を使って、RSAのように公開鍵で暗号化したい」

そんなところから始めたのですが、BouncyCastleのソースコードを追いかけている途中で、何度も目に入ってきた名前があります。

ECDHBasicAgreement

ECDH。

ECIESを使いたかっただけなのに、なぜECDHが出てくるのでしょうか。調べてみると、ECIESの中で重要なのは、楕円曲線そのものでデータを直接暗号化することではなく、
楕円曲線を使って、相手と同じ秘密を作ること
でした。

今回はいったんECIESから離れ、ECDHだけを取り出して動かしてみます。

ECDHは何をするものなのか

ECDHは、Elliptic Curve Diffie-Hellman の略です。RSAのように、

公開鍵で暗号化
↓
秘密鍵で復号

する方式ではありません。

ECDHが行うのは、
離れた二者が、秘密そのものを通信経路へ流さずに、同じ秘密を作ること
です。
そして、その共有秘密から実際にデータを暗号化するための鍵を作ります。

ECDHそのものがデータを暗号化するわけではありません。ECDHで共有秘密を作り、KDFで暗号鍵を導出し、その鍵をAES-GCMなどの共通鍵暗号で使用します。

今回試す処理を大きく並べると、

鍵ペア生成
↓
公開鍵交換
↓
ECDH
↓
共有秘密
↓
KDF
↓
AES鍵
↓
AES-GCM

となります。
最終的に "Hello world" を暗号化し、もう一方で復号できるところまで試します。

まず、双方が同じ楕円曲線を使う

ECDHを考える前に、一つ重要な前提があります。
ServerとClientは、それぞれ勝手な楕円曲線を使うわけではありません。
同じ楕円曲線のドメインパラメータを使います。たとえば今回 secp256k1 を使うなら、双方が共通して、

楕円曲線
基準点 G
G の位数 n
cofactor h

などを知っています。特に重要なのが、基準点 G です。G は秘密鍵から作るものではありません。
ServerとClientが、最初から共有している楕円曲線上の基準点です。

双方が同じ基準点 G を使う図
ServerとClientは同じ楕円曲線と同じ基準点 G を使います。それぞれ異なるのは秘密鍵です。

Serverが秘密鍵 s を選んだとします。その公開鍵は、

S = sG

です。Clientは別の秘密鍵 c を選び、

C = cG

を公開鍵にします。つまり、

Server
秘密鍵 : s
公開鍵 : S = sG

Client
秘密鍵 : c
公開鍵 : C = cG

です。同じ G を出発点として、異なる秘密の整数を掛けて、それぞれの公開鍵を作っています。

ネットワークを流れるのは公開鍵だけ

次に、ServerとClientは公開鍵を交換します。Serverは、

S = sG

をClientへ送ります。Clientは、

C = cG

をServerへ送ります。しかし、

秘密鍵 s
秘密鍵 c

は送りません。

Server / Client が公開鍵だけを交換する図
通信経路を流れるのは公開鍵 S と C だけです。秘密鍵 s と c は、それぞれの手元から出ません。

ここまでは、少し不思議です。相手から受け取ったのは公開鍵だけです。
それなのに、このあとServerとClientは、同じ秘密の値を計算できます。

なぜ同じ共有秘密になるのか

Serverは、

自分の秘密鍵 s

と、

Clientから受け取った公開鍵 C

を使います。計算するのは、

sC

です。Clientの公開鍵は、

C = cG

なので、

sC
= s(cG)
= (sc)G
= scG

となります。一方、Clientでは、

cS

を計算します。Serverの公開鍵は、

S = sG

なので、

cS
= c(sG)
= (cs)G
= csG

です。整数の掛け算では、

sc = cs

なので、

scG = csG

となります。したがって、

sC = cS

です。

sC = cS = scG の導出図
Serverは「自分の秘密鍵 × Clientの公開鍵」、Clientは「自分の秘密鍵 × Serverの公開鍵」を計算します。出発点となる G が共通なので、最終的に同じ楕円曲線上の点へ到達します。

ECDHのポイントは、

Server : s × C
Client : c × S

です。

交換したのは公開鍵だけなのに、

Server : scG
Client : csG

となり、双方で同じ値を得られます。秘密鍵そのものを相手に送る必要がありません。

BouncyCastleでECDHを試す

では、C#で実際に試してみます。今回もNuGetから、

BouncyCastle.Cryptography

を使用します。

まず、双方で使用する楕円曲線のパラメータを作ります。

C#
using Org.BouncyCastle.Asn1.Sec;
using Org.BouncyCastle.Crypto;
using Org.BouncyCastle.Crypto.Agreement;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Math;
using Org.BouncyCastle.Security;
using Org.BouncyCastle.Utilities;
using System.Security.Cryptography;
using System.Text;

var curve = SecNamedCurves.GetByName("secp256k1");

var domain = new ECDomainParameters(
    curve.Curve,
    curve.G,
    curve.N,
    curve.H,
    curve.GetSeed()
);

ここで、

curve.G

が、先ほど図で出てきた共通の基準点 G です。ServerもClientも、この同じ domain を使います。

ServerとClientの鍵ペアを作る

C#
static AsymmetricCipherKeyPair GenerateKeyPair(
    ECDomainParameters domain)
{
    var generator = new ECKeyPairGenerator();

    generator.Init(
        new ECKeyGenerationParameters(
            domain,
            new SecureRandom()
        )
    );

    return generator.GenerateKeyPair();
}
C#
var serverKeyPair = GenerateKeyPair(domain);
var clientKeyPair = GenerateKeyPair(domain);

これで、それぞれが、

Server
秘密鍵 s
公開鍵 S = sG

Client
秘密鍵 c
公開鍵 C = cG

に相当する鍵ペアを持ちました。

Server側で共有秘密を計算する

C#
var serverAgreement = new ECDHBasicAgreement();

serverAgreement.Init(
    (ECPrivateKeyParameters)serverKeyPair.Private
);

BigInteger serverSharedValue =
    serverAgreement.CalculateAgreement(
        (ECPublicKeyParameters)clientKeyPair.Public
    );

Serverが使っているのは、

Serverの秘密鍵
+
Clientの公開鍵

です。数式で書けば、

sC

を計算しています。

Client側でも共有秘密を計算する

C#
var clientAgreement = new ECDHBasicAgreement();

clientAgreement.Init(
    (ECPrivateKeyParameters)clientKeyPair.Private
);

BigInteger clientSharedValue =
    clientAgreement.CalculateAgreement(
        (ECPublicKeyParameters)serverKeyPair.Public
    );

Client側は、

Clientの秘密鍵
+
Serverの公開鍵

なので、

cS

です。

理屈では同じ値になるはずです。実際に比較してみます。

本当に同じ値になるのか

C#
int secretLength =
    (domain.Curve.FieldSize + 7) / 8;

byte[] serverSecret =
    BigIntegers.AsUnsignedByteArray(
        secretLength,
        serverSharedValue
    );

byte[] clientSecret =
    BigIntegers.AsUnsignedByteArray(
        secretLength,
        clientSharedValue
    );

Console.WriteLine(
    Convert.ToHexString(serverSecret)
);

Console.WriteLine(
    Convert.ToHexString(clientSecret)
);

Console.WriteLine(
    serverSecret.SequenceEqual(clientSecret)
);

最後の結果は、

True

になります。ServerとClientは秘密鍵を交換していません。それぞれ、

自分の秘密鍵
+
相手の公開鍵

しか使っていないのに、同じ共有秘密を得ることができました。これがECDHです。

共有秘密をそのままAES鍵にしていいのか

ここまで来ると、「同じ共有秘密ができたなら、それをAESの鍵として使えばいいのでは?」と考えたくなります。

実際、以前試していたコードでは、かなり素朴に、

sharedSecret.Take(32)

としていました。共有秘密の先頭32byteを取ってAES-256の鍵にする、という考え方です。

共有秘密 → HKDF → AES鍵 → AES-GCM と、Take(32) のNG例を示す図
ECDHの出力をそのまま暗号鍵として切り出すのではなく、KDFを通して用途に適した鍵を導出します。

ECDHで得られた共有秘密を、そのままAES鍵として使用するのは避けます。

実際の暗号鍵を作るために、KDF ― Key Derivation Functionを使用します。今回使用するのはHKDF-SHA256です。

HKDFでAES鍵を作る

流れは、

ECDH
↓
共有秘密
↓
HKDF-SHA256
↓
32byte
↓
AES-256鍵

です。

HKDFには共有秘密以外に、salt と info も渡します。

C#
using Org.BouncyCastle.Crypto.Digests;
using Org.BouncyCastle.Crypto.Generators;
using Org.BouncyCastle.Crypto.Parameters;

static byte[] DeriveKey(
    byte[] sharedSecret,
    byte[] salt)
{
    byte[] info =
        Encoding.UTF8.GetBytes(
            "bricklayer.work ECDH demo v1"
        );

    var hkdf =
        new HkdfBytesGenerator(
            new Sha256Digest()
        );

    hkdf.Init(
        new HkdfParameters(
            sharedSecret,
            salt,
            info
        )
    );

    byte[] key = new byte[32];

    hkdf.GenerateBytes(
        key,
        0,
        key.Length
    );

    return key;
}

salt はランダムに作ります。

C#
byte[] salt =
    RandomNumberGenerator.GetBytes(32);

salt は秘密情報である必要はありません。
暗号文と一緒に相手へ送って構いません。HKDFでは、同じ共有秘密を使っていても、salt や info によって用途の異なる鍵を導出できます。

C#
byte[] serverKey =
    DeriveKey(serverSecret, salt);

byte[] clientKey =
    DeriveKey(clientSecret, salt);

Console.WriteLine(
    serverKey.SequenceEqual(clientKey)
);

こちらも、

True

になります。ECDHで同じ共有秘密を得ているので、同じ salt と info をHKDFへ与えれば、同じAES鍵を導出できます。

AES-GCMでHello worldを暗号化する

ここからは楕円曲線暗号ではなく、普通の共通鍵暗号です。

Server側で、

Hello world

をAES-GCMで暗号化します。

C#
byte[] plainText =
    Encoding.UTF8.GetBytes("Hello world");

byte[] nonce =
    RandomNumberGenerator.GetBytes(12);

byte[] cipherText =
    new byte[plainText.Length];

byte[] tag =
    new byte[16];

using (var aes =
    new AesGcm(serverKey, 16))
{
    aes.Encrypt(
        nonce,
        plainText,
        cipherText,
        tag
    );
}

暗号化に使っている serverKey は、

ECDH
↓
共有秘密
↓
HKDF

という流れで作った鍵です。

Client側でも同じ鍵を導出できているので、その鍵を使って復号します。

C#
byte[] decrypted =
    new byte[cipherText.Length];

using (var aes =
    new AesGcm(clientKey, 16))
{
    aes.Decrypt(
        nonce,
        cipherText,
        tag,
        decrypted
    );
}

Console.WriteLine(
    Encoding.UTF8.GetString(decrypted)
);

結果は、

Hello world

です。ついに、

公開鍵だけを交換
↓
ECDHで共有秘密
↓
HKDFでAES鍵
↓
AES-GCMで暗号化
↓
相手側で復号

までつながりました。

ここまで来るとECIESが少し見えてくる

前回は、「ECCでRSAのような公開鍵暗号化をしたい」というところからECIESへ辿り着きました。

ところがECDHを自分で組み立ててみると、楕円曲線でデータ本体を直接暗号化する必要はないことが分かってきます。必要なのは、おおまかには、

楕円曲線による鍵共有
+
KDF
+
共通鍵暗号

です。ECIESも、このような考え方を組み合わせたハイブリッド暗号です。ただし、実際のECIESでは一時鍵の扱いやMAC、KDF、暗号方式など、方式や実装によって細部が異なります。

このあたりは次回もう少し掘ってみます。

ただしECDHだけでは安全な通信にはならない

ここまでを見ると、

「ECDHで秘密鍵を送らずに同じ鍵を作れるなら、これで安全なのでは?」

と思えます。

しかし、まだ大きな問題があります。

ECDHそのものは、受け取った公開鍵が、本当に相手の公開鍵なのかを確認してくれません。

ECDHだけでは相手を認証できず、中間者攻撃が成立する図
攻撃者が通信経路で公開鍵を差し替えると、ServerとClientはそれぞれ攻撃者との共有秘密を作ってしまう可能性があります。

たとえば攻撃者が途中に入り、

Serverの公開鍵 S

を自分の公開鍵に差し替え、さらに、

Clientの公開鍵 C

も別の公開鍵に差し替えたとします。すると、

Server ←→ 攻撃者

と、

攻撃者 ←→ Client

の間に、それぞれ別の共有秘密が成立してしまいます。攻撃者は受信したデータを復号し、内容を読んでから再び暗号化して転送することも可能になります。

ECDHは鍵共有の仕組みであって、安全な通信プロトコルそのものではありません。
実際の通信では、証明書、デジタル署名、事前共有鍵などを使って、相手が本当に意図した通信相手なのかを確認する仕組みが必要になります。

どこかで見た構造

今回のECDHで何度も出てきたのは、

自分の秘密鍵
+
相手の公開鍵

という組み合わせでした。

これを眺めていて、ふと見覚えのある設定ファイルを思い出しました。

INI
[Interface]
PrivateKey = ...

[Peer]
PublicKey = ...

WireGuardの wg0.conf です。

自分については、

PrivateKey

を持ち、相手については、

PublicKey

を持つ。

もちろん、WireGuardが今回書いた単純なECDHコードをそのまま使っているわけではありません。使用している曲線も違いますし、WireGuardはNoise Protocol Frameworkをベースに、静的鍵だけでなく一時鍵なども組み合わせた鍵交換を行います。

それでも、

「自分の秘密鍵」と「相手の公開鍵」

という構造が突然身近なものに見えてきました。

まとめ

今回は、前回BouncyCastleのECIESを追いかけている途中で見つけたECDHを、単独で動かしてみました。ServerとClientはそれぞれ秘密鍵を持ち、同じ基準点 G から公開鍵を作ります。交換するのは公開鍵だけです。それでも、

Server : sC = scG

Client : cS = csG

となり、同じ共有秘密を得ることができます。その共有秘密をHKDFへ渡してAES鍵を作り、AES-GCMで実際に暗号化・復号するところまで確認できました。

最初は、

楕円曲線暗号でRSAのように暗号化したい

だけだったのですが、ここまで来ると、

楕円曲線暗号は「データを直接暗号化する」というより、まず安全に鍵を作るところで活躍する

という見え方に変わってきます。

次回

次回は、

C#で楕円曲線暗号を使いたい③
― ECDHからWireGuardとECIESの正体が見えてきた

です。ECDHで見えてきた、

自分の秘密鍵
+
相手の公開鍵

という構造を手掛かりに、WireGuardを覗いてみます。そして、最初に使おうとして失敗したECIESが、結局何をしていたのかも改めて整理します。

返信を残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

CAPTCHA