

暗号化と認証②
鍵ペア、暗号化、署名、そして公開鍵すり替え
前回は、「暗号化」と「認証」が安全な通信では別の問題であることを見ました。
そしてC#でAES-GCMを実際に動かしてみると、暗号化と復号には同じ共通鍵 key が必要でした。
そこで新しい問題が出てきました。
その共通鍵を、相手へどうやって安全に渡すのか。
今回は、そこから一歩進んで公開鍵暗号を見ていきます。
公開鍵暗号では、
公開鍵 相手へ渡してよい 秘密鍵 本人だけが持つ
という2本の鍵を使います。
さらに今回は、言葉と図だけでは終わらせません。
C#で実際にRSA鍵を作り、暗号化・復号・署名・検証まで動かしてみます。
鍵ペアとは何か

公開鍵暗号では、1本の鍵ではなく、2本で1組の鍵を使います。
これが 鍵ペア です。
- 秘密鍵:自分だけが持つ鍵
- 公開鍵:相手に渡してよい鍵
この2つは無関係な鍵を別々に作るのではありません。
最初から数学的な関係を持った1組として生成されます。
鍵ペア
┌─────┴─────┐
│ │
公開鍵 秘密鍵
配ってよい 外へ出さない
公開鍵は、その名前の通り公開して構いません。
重要なのは、
秘密鍵を守ること
そして、
受け取った公開鍵が本当に相手のものか確認すること
この2つ目が、この記事の後半で重要になります。
C#でRSAの鍵ペアを作ってみる
では、本当にC#で鍵ペアを作ってみます。
.NETには System.Security.Cryptography.RSA が用意されています。
using System.Security.Cryptography;
using System.Text;
using RSA juliet = RSA.Create(2048);これで、ジュリエットが持つRSA鍵が生成されました。
この juliet は、公開鍵だけでなく秘密鍵側の情報も持っています。
次に、そこから公開してよい部分だけを取り出します。
byte[] julietPublicKey =
juliet.ExportSubjectPublicKeyInfo();ExportSubjectPublicKeyInfo() は、鍵のうち公開鍵部分をX.509 SubjectPublicKeyInfo形式で取り出します。
これをロミオへ渡したとします。
ロミオ側では、その公開鍵だけを別のRSAオブジェクトへ読み込みます。
using RSA julietPublic = RSA.Create();
julietPublic.ImportSubjectPublicKeyInfo(
julietPublicKey,
out _
);ここで2つのオブジェクトができました。
juliet 公開鍵 + 秘密鍵を持つ ジュリエット本人が保持 julietPublic 公開鍵だけを持つ ロミオへ渡せる
ImportSubjectPublicKeyInfo() は、公開鍵情報をRSAオブジェクトへ読み込むAPIです。
つまり、「公開鍵は相手へ渡してよい」という説明が、実際のC#オブジェクトとして見えるようになりました。
公開鍵で暗号化するとはどういうことか

まずは、暗号化です。
ロミオがジュリエットへ、
明日の10時に会いましょう
と送りたいとします。
このときロミオが使うのは、ジュリエットの公開鍵です。
ロミオ
ジュリエットの公開鍵
↓
暗号化
↓
暗号文
↓
ジュリエット
自分の秘密鍵
↓
復号
ここで守りたいのは、主に機密性です。
第三者が暗号文を途中で取得しても、ジュリエットの秘密鍵を持っていなければ内容を読めないようにします。
C#で公開鍵暗号を動かしてみる
先ほど作った julietPublic は、公開鍵しか持っていません。
まずメッセージをバイト列へ変換します。
byte[] message =
Encoding.UTF8.GetBytes(
"明日の10時に会いましょう"
);そして、ジュリエットの公開鍵を使って暗号化します。
byte[] cipherText =
julietPublic.Encrypt(
message,
RSAEncryptionPadding.OaepSHA256
);ここではRSAの暗号化方式として、OAEPとSHA-256を使っています。
大事なのは、暗号化を実行したオブジェクトが、
julietPublic
だということです。
つまり、秘密鍵を持っていない側でも暗号化できます。
今度はジュリエットが復号します。
byte[] plainText =
juliet.Decrypt(
cipherText,
RSAEncryptionPadding.OaepSHA256
);
Console.WriteLine(
Encoding.UTF8.GetString(plainText)
);結果は、
明日の10時に会いましょう
となります。
C#でも、
ジュリエットの公開鍵
↓
Encrypt()
↓
暗号文
↓
ジュリエットの秘密鍵
↓
Decrypt()
↓
平文
という関係を確認できました。
公開鍵で暗号化し、対応する秘密鍵で復号する。
これは「相手だけが読めるようにする」ための使い方です。
ここでは仕組みを確認するため、短いメッセージをRSAで直接暗号化しています。
ただしRSAには暗号化できるデータ長の制限があり、大量の通信データをそのままRSAで暗号化する用途には向きません。
実際のシステムでは、①で見たAESなどの高速な共通鍵暗号と公開鍵暗号・鍵共有の仕組みを組み合わせるのが基本になります。
署名は暗号化とは別の役割を持つ

次は署名です。
署名の目的は、暗号化とは違います。
確認したいのは、
- その署名が、対応する秘密鍵で作られたものか
- メッセージが途中で書き換えられていないか
です。
流れはこうなります。
ロミオ
自分の秘密鍵
↓
署名
↓
メッセージ + 署名
↓
ジュリエット
ロミオの公開鍵
↓
検証
ここで重要なのは、
署名は、メッセージを読めなくするための処理ではない
ということです。
C#で署名してみる
ロミオ用のRSA鍵ペアを作ります。
using RSA romeo = RSA.Create(2048);
byte[] romeoPublicKey =
romeo.ExportSubjectPublicKeyInfo();
using RSA romeoPublic = RSA.Create();
romeoPublic.ImportSubjectPublicKeyInfo(
romeoPublicKey,
out _
);romeo が秘密鍵を含む鍵ペア。
romeoPublic が、ジュリエットへ渡す公開鍵です。
では、先ほどと同じメッセージへ署名します。
byte[] signature =
romeo.SignData(
message,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss
);そしてジュリエット側で、ロミオの公開鍵を使って検証します。
bool valid =
romeoPublic.VerifyData(
message,
signature,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss
);
Console.WriteLine(valid);結果は、
True
です。
.NETの SignData() は指定されたハッシュアルゴリズムでデータをハッシュし、その結果を使って署名を作ります。VerifyData() はデータから再びハッシュを計算し、渡された署名が正しいか検証します。
メッセージを1文字でも変えたらどうなるか
では、ティボルトが途中で、
明日の10時に会いましょう
を、
明日の12時に会いましょう
へ書き換えたとします。
byte[] tamperedMessage =
Encoding.UTF8.GetBytes(
"明日の12時に会いましょう"
);元の署名を、その改ざん後のメッセージで検証します。
bool validAfterTampering =
romeoPublic.VerifyData(
tamperedMessage,
signature,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss
);
Console.WriteLine(validAfterTampering);結果は、
False
となります。
署名を作ったときのデータと、検証時のデータが違うためです。
署名では、
元のメッセージ
+
秘密鍵で作った署名
↓
公開鍵で検証
↓
True
メッセージを書き換える
↓
同じ署名で検証
↓
False
という形で、データの改ざんを検出できます。
「秘密鍵で暗号化」が署名なのか
公開鍵暗号を初めて学ぶと、
公開鍵で暗号化して秘密鍵で復号する。
逆に、秘密鍵で暗号化して公開鍵で復号するのが署名。
という説明を見かけることがあります。
考え方の入口としては分かりやすいのですが、実際のデジタル署名をそのまま「秘密鍵で暗号化」と考えるのは正確ではありません。
C#のAPIを見ると、この違いがかなり分かりやすくなります。
暗号化は、
Encrypt(...)
Decrypt(...)です。
一方、署名は、
SignData(...)
VerifyData(...)です。
最初から別のAPIになっています。
暗号化 Encrypt ↓ Decrypt 署名 SignData ↓ VerifyData
しかも SignData() は、単純にメッセージ全体を「秘密鍵で暗号化」しているわけではありません。
今回なら、
メッセージ ↓ SHA-256 ↓ 署名処理 ↓ RSA-PSS署名
という処理になります。
したがって、
「秘密鍵で暗号化=署名」
と覚えてしまうより、
暗号化と署名は、鍵ペアを使う点では似ているが、目的も処理も別
と理解した方が安全です。
暗号化と署名の違いを左右で見比べる

ここまでの内容を並べると、違いがかなりはっきりします。
暗号化
- 相手の公開鍵を使う
- 相手の秘密鍵で復号する
- 守りたいのは 機密性
- 主眼は 「第三者に読まれないこと」
署名
- 自分の秘密鍵を使う
- 自分の公開鍵で検証する
- 改ざんの検出や署名の検証に使う
- 主眼は 「その公開鍵に対応する秘密鍵で作られた署名か」
つまり、
暗号化
秘密に届ける
署名
対応する秘密鍵で署名されたかと
改ざんの有無を確認する
という違いがあります。
ただし、ここで一つ重要な注意があります。VerifyData() が True になったからといって、
「ロミオ本人であること」が自動的に証明されたわけではありません。
分かるのは、
「検証に使った公開鍵に対応する秘密鍵で、この署名が作られた」
ということです。
では、その公開鍵自体が偽物だったらどうなるのでしょうか。
でも、公開鍵が偽物だったらどうなるか

ここで大きな問題が出てきます。
公開鍵暗号は便利ですが、大前提があります。
受け取った公開鍵が、本当にその相手のものなのか。
「受け取った公開鍵が本当に相手のものか」
これを確認できなければなりません。
例えばティボルトが途中へ入り込み、
これがジュリエットの公開鍵です。
と言って、実際には自分の公開鍵をロミオへ渡したとします。
ロミオがそれを信じたらどうなるでしょう。
C#で公開鍵すり替えを再現してみる
ティボルトもRSA鍵ペアを作ります。
using RSA tybalt = RSA.Create(2048);
byte[] tybaltPublicKey =
tybalt.ExportSubjectPublicKeyInfo();ところがロミオは、この公開鍵を、
ジュリエットの公開鍵
だと思い込んでいます。
using RSA supposedJulietPublic =
RSA.Create();
supposedJulietPublic.ImportSubjectPublicKeyInfo(
tybaltPublicKey,
out _
);変数名は supposedJulietPublic。
つまり、
「ジュリエットの公開鍵だと思っているもの」
です。
ロミオは何のエラーもなく暗号化できます。
byte[] stolenCipherText =
supposedJulietPublic.Encrypt(
message,
RSAEncryptionPadding.OaepSHA256
);RSAの暗号化処理自体は正常に成功します。
しかし、この暗号文を復号できるのは誰でしょう。
byte[] stolenPlainText =
tybalt.Decrypt(
stolenCipherText,
RSAEncryptionPadding.OaepSHA256
);
Console.WriteLine(
Encoding.UTF8.GetString(stolenPlainText)
);結果は、
明日の10時に会いましょう
です。
ティボルトが読めてしまいました。
ここでRSAは一度も破られていません。Encrypt() も Decrypt() も正常に動いています。
問題はただ一つ。
ロミオが、ティボルトの公開鍵をジュリエットの公開鍵だと信じた。
これだけです。
署名でも同じ問題が起きる
さらに、署名でも同じ問題があります。
ティボルトが自分の秘密鍵で署名します。
byte[] fakeSignature =
tybalt.SignData(
message,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss
);そしてティボルトの公開鍵を、
これはロミオの公開鍵です。
と偽って渡したとします。
using RSA supposedRomeoPublic =
RSA.Create();
supposedRomeoPublic.ImportSubjectPublicKeyInfo(
tybaltPublicKey,
out _
);その偽物の公開鍵で検証します。
bool fakeValid =
supposedRomeoPublic.VerifyData(
message,
fakeSignature,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pss
);
Console.WriteLine(fakeValid);結果は、
True
になります。
ここはかなり重要です。
VerifyData() が True なのに、ロミオではない。
なぜでしょう。
VerifyData() が確認したのは、
この署名が、検証に使った公開鍵と対応する秘密鍵で作られたか。
だからです。
その公開鍵が本当にロミオのものかどうかまでは、RSA署名そのものは教えてくれません。
署名検証
↓
True
↓
この公開鍵に対応する
秘密鍵で署名された
↓
しかし
↓
その公開鍵が本当に
ロミオのものかは別問題
ここで、①から繰り返してきた話へ戻ってきます。
「暗号技術が正しく動いた」ことと
「相手が本物である」ことは別です。
そこで電子証明書が登場する
では、どうやって
「この公開鍵は本当にその相手のものです」
と証明するのでしょうか。
そこで登場するのが、電子証明書です。
電子証明書は、ざっくり言えば
- この公開鍵は
- この主体のものであり
- それを信頼できる第三者が保証している
という仕組みです。
つまり、今回見た公開鍵すり替えの問題は、
電子証明書はなぜ必要か
を理解する入り口でもあります。
暗号化だけ見ていると「便利な鍵」ですが、
安全な通信全体で見ると、
- 鍵ペア
- 暗号化
- 署名
- 公開鍵の真正性確認
- 電子証明書
が一本につながってきます。
WebのHTTPSでは、電子証明書を使って、
公開鍵
+
その公開鍵を使う主体の情報
+
証明書への署名
↓
公開鍵と相手の結び付きを検証
していきます。
もちろん、
証明書があるから無条件に信用する
わけではありません。
最終的には、
- 誰が証明書へ署名したのか
- その署名者を信用できるのか
- 証明書の有効期限はどうか
- 接続先の名前と証明書は一致しているか
といった検証が必要になります。
この話は、この後の証明書チェーンの記事で詳しく追います。
まとめ
今回は、公開鍵暗号を図だけでなく、C#でも実際に動かしてみました。
RSA.Create()で公開鍵と秘密鍵の鍵ペアを作れるExportSubjectPublicKeyInfo()で公開鍵だけを取り出せるEncrypt()とDecrypt()で公開鍵暗号を確認できるSignData()とVerifyData()で電子署名を確認できる- メッセージを書き換えると、署名検証は
Falseになる - 暗号化と署名は、API上でも別の処理
- 公開鍵をすり替えると、暗号アルゴリズムが正常でも攻撃が成立する
VerifyData() == trueでも、その公開鍵が本当に本人のものかは別問題- WebのHTTPSでは、公開鍵の真正性を確認するために電子証明書が使われる
今回、一番重要なのは、
暗号化が成功した ≠ 相手が本物 署名検証が成功した ≠ 公開鍵の持ち主が本人
という点です。
公開鍵暗号を安全に使うには、
その公開鍵が誰のものなのか
まで確認しなければなりません。
C#のAPIを実際に動かしてみると、この違いがかなりはっきり見えます。
Encrypt / Decrypt
と、
SignData / VerifyData
は別のAPIです。
さらに、そのすべてが正常に動いても、渡された公開鍵そのものが偽物なら安全ではない。
ここまで来ると、電子証明書が必要になる理由も自然につながってきます。
次回
さて次回は、
暗号化と認証③ ― RSAを小さな数字から理解する
公開鍵と秘密鍵はどうやって作られるのか
です。
今回、C#では、
using RSA juliet = RSA.Create(2048);
と書くだけでRSA鍵が作れてしまいました。
でも、ここで新しい疑問が出てきます。
RSA.Create()は、いったい何を作っているのか。
公開鍵と秘密鍵は「数学的に関係した1組」と説明しました。
では、その関係とは何なのでしょう。
次回は、あえて小さな数字を使って、
2つの素数 p, q
↓
n = pq
↓
φ(n)
↓
公開指数 e
↓
秘密指数 d
↓
公開鍵 (n, e)
秘密鍵 (n, d)
まで、自分の手で作ります。
②では、
C#の RSA クラスを使って、公開鍵暗号を外側から動かしました。
③では、
その RSA クラスの下にある数学を、自分で再現します。
そして最終的には、
BigInteger.ModPow(...)
へつなげていきます。
次回、RSA.Create() が隠してくれていた中身を、数式まで降りて見てみます。