エンドツーエンド暗号化(end-to-end encryption、E2EE)をRubyで実装してみる。
この暗号化はメッセンジャーアプリやクラウドストレージなどで利用されており、正しく実装すれば盗聴や改ざんのリスクを低減することができます。
エンドツーエンド暗号化とは
エンドツーエンド暗号化(end-to-end encryption、E2EE)は、通信の送信者から受信者までデータを暗号化する技術です。送信者のデバイスで暗号化されたデータは、受信者のデバイスでのみ復号可能であり、途中のサーバーや第三者が内容を解読することはできません。このため、盗聴や改ざんのリスクが大幅に低減され、プライバシーとセキュリティが強化されます。
暗号化アルゴリズムや暗号化プロトコルにはたくさんの種類があり、誤用しやすく、潜在的な落とし穴がたくさんあります。
安全なセキュリティシステムの設計を正しく行うことは非常に難しく、通常はセキュリティ専門家の領域となります。
設計や実装にほんの小さな間違いがあるだけでセキュリティが完全に無効になる可能性があります。
この記事の作者はセキュリティ専門家ではありません。できるだけ正しい情報を書くよう心掛けていますが、書かれた情報の正しさはご自身で検証してください。
エンドツーエンド暗号化のよくある誤解
エンドツーエンド暗号化を実装しただけで誰にも知られずに通信できるアプリケーションを宣言するのは間違いです。
「送信者から受信者までのデータを暗号化すること」はエンドツーエンド暗号化の最低限の構成要素でしかなく、幅広く利用されるアプリケーションではこれだけでは不十分です。
現実的には、中間者攻撃を防ぐための認証の提供、鍵が漏洩しても過去のデータを復号化できなくする前方秘匿性の提供などが必要です。これらの要素を提供する通信方式には、一例としてSignalプロトコルがあります。
HTTPS(TLS)との違い
HTTPSも通信内容の暗号化を行うが、こちらはエンドユーザーからサーバーまでの通信路を暗号化します。つまり、サーバーの管理者(= サービスの運営者)には通信内容が全て見えてしまいます。
今回のRuby実装
今回は公開鍵暗号方式と共通鍵暗号方式を組み合わせたハイブリッド暗号方式でのエンドツーエンド暗号化を実現します。こうすることで、それぞれの暗号方式の利点を同時に利用することができます。
今回のハイブリッド暗号方式において、AとBは以下の手順で暗号化通信を行います。
- Aは共通鍵を生成する
- Aは共通鍵を公開鍵暗号方式で暗号化してBに送信する
- AとBは共有された共通鍵を用いて共通鍵暗号方式で通信を暗号化する
注意1:共通鍵の共有方法にはいくつかの種類があり、代表的なものとしてディフィー・ヘルマン鍵共有(鍵交換)やその発展型のECDH、D3DHがあります。
注意2:今回採用した方式は非常に単純なものです。世界的なメッセージングアプリではSignalプロトコルがよく採用されています。これはX3DH鍵交換とダブルラチェットセッションによる連続的な鍵更新から成り立っています。
ハイブリッド暗号方式がほぼ必須な理由
お互いの公開鍵で暗号文の送受信が実現できるのであれば公開鍵暗号方式だけでよさそうですが、それは現実的に不可能です。理由は、RSA暗号で暗号化できるメッセージの大きさは鍵生成の時に使われたセキュリティパラメーターの大きさによって制限されるからです。例えば、4096ビットの鍵を使った場合のメッセージサイズは512バイトしかありません。
独自実装は避けるべき
今回紹介する単純な実装だけでは悪意を持った第三者による現実的な脅威に対処できません。また、独自に実装するとバグ混入の可能性が高くなります。特に暗号化や認証、セキュリティ関連の処理を独自実装するのは大変危険です。
共通鍵の生成
共通鍵暗号方式では送信者と受信者の双方で同じ共通鍵を利用することになります。
信頼できる疑似乱数生成器があれば、その結果をそのまま共通鍵に使用できます。今回は単純化のためにこのような実装をしていますが、疑似乱数をそのまま鍵として使用するのは避けるべきです。(後述)
require 'openssl'
OpenSSL::Random.seed(File.read("/dev/random", 16))
cipher = OpenSSL::Cipher.new("AES-256-CBC")
key = OpenSSL::Random.random_bytes(cipher.key_len)
key
が今回生成した共通鍵です。公開してはいけません。
OpenSSL::Cipher.new("AES-256-CBC")
は鍵の長さを取得するために利用しているだけで、ここで暗号化をしているわけではありません。
疑似乱数生成器の出力を利用するだけでは不十分
Rubyの標準ライブラリにはRFC2898で標準化されているPBKDF2アルゴリズムの実装が含まれているため、疑似乱数をそのまま用いるのではなく、こちらの方式で共通鍵を生成するべきです。
このアルゴリズムは、「パスワードをハッシュ化することで共通鍵を生成する。その際、ソルトを加え、ストレッチすることでハッシュ値から元のパスワードを推測することを困難にする」というものです。そのため、最初だけパスワードが必要になります。
pass = "password"
salt = OpenSSL::Random.random_bytes(8)
cipher = OpenSSL::Cipher.new("AES-256-CBC")
key_iv = OpenSSL::PKCS5.pbkdf2_hmac_sha1(pass, salt, 2000, cipher.key_len + cipher.iv_len)
key = key_iv[0, cipher.key_len]
iv = key_iv[cipher.key_len, cipher.iv_len]
key
が今回生成した共通鍵です。公開してはいけません。
iv
はInitialization vector(初期化ベクトル)で、復号化の際に必要です。異なる平文に対して毎回違う値を使用する必要があります。
OpenSSL::Cipher.new("AES-256-CBC")
は鍵の長さと初期化ベクトルの長さを取得するために利用しているだけで、ここで暗号化をしているわけではありません。
共通鍵を用いた暗号化
共通鍵を用いた暗号化は以下の通りです。
enc = OpenSSL::Cipher.new("AES-256-CBC")
enc.encrypt
enc.key = key
enc.iv = iv
data = "secret data"
encrypted_data = ""
encrypted_data << enc.update(data)
encrypted_data << enc.final
共通鍵を用いた復号化は以下の通りです。
dec = OpenSSL::Cipher.new("AES-256-CBC")
dec.decrypt
dec.key = key
dec.iv = iv
decrypted_data = ""
decrypted_data << dec.update(encrypted_data)
decrypted_data << dec.final
今回用いたAES-256-CBCは認証なしの暗号化方式です。この方式では改ざんを検出できず安全ではありません。
実用上は認証付きの暗号化ができるAEAD暗号化方式を用いるべきです。AEAD暗号化方式で有名なものには、AES-GCMやChaCha20-Poly1305があります。
公開鍵と秘密鍵の生成
共通鍵暗号方式では、送信者は受信者の公開鍵を用いて平文を暗号化し、受信者は自分の秘密鍵で復号化します。
公開鍵暗号方式で用いる秘密鍵と公開鍵はopensslコマンドで生成する方法がよく知られています。
# 秘密鍵の生成
openssl genrsa 2048 >private_key.pem
# 公開鍵の生成
cat private_key.pem | openssl rsa -pubout >public_key.pem
今回はこの鍵もRubyで生成します。秘密鍵の生成は以下の通りです。秘密鍵は公開してはいけません。
private_key = OpenSSL::PKey::RSA.generate(2048)
公開鍵の生成は以下の通りです。公開鍵は公開する必要があります。
private_key.public_key
RSA形式の秘密鍵と公開鍵を紹介した理由は、歴史が長く様々なプログラミング言語で利用できるからです。
現代的なアプリケーションで「相手と安全に(共通鍵暗号方式の)鍵の共有を行うこと」を実現したいのであれば、今回紹介したRSA暗号化(による通信で鍵の共有を行う)ではなく、X25519形式の秘密鍵と公開鍵を用いたECDHの方がより良いと思います。
公開鍵と秘密鍵を用いた暗号化
公開鍵を用いた暗号化は以下の通りです。
plaintext = "secret data"
ciphertext = public_key.public_encrypt(plaintext,
OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
秘密鍵を用いた復号化は以下の通りです。
plaintext = private_key.private_decrypt(ciphertext,
OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
はRSA暗号におけるパディング形式の指定です。RSA暗号における「パディング」は既知の脆弱性(例:ミリオンメッセージ攻撃)を防ぐために必須の要素です。現時点の実装では必ずこの値を指定する必要があります。
RSA暗号は今後避けるべき
RSA-OAEPの設計も完璧ではなく、さらに優れた構成法が標準化されています。その1つがRSA-KEMです。セキュリティが向上している上、ずっと容易に実装できます。
その一方で、鍵交換とハイブリッド暗号のどちらであっても、RSA暗号からECDHに移行するプロトコルが増えつつあります。その理由は、ECDHの方が公開鍵を小さくできる上に標準として優れているためです。
これらの現状から、互換性の問題がある場合を除き、新しいアプリケーションでのRSA暗号の使用は避けるべきです。
エンドツーエンド暗号化
これまでに説明した暗号化と復号化を以下のように組み合わせることで、エンドツーエンド暗号化を実現できます。
- RSA暗号で共通鍵を暗号化し相手に送信する
- 共通鍵の共有が完了する
- この共通鍵を用いたAES暗号で送受信するデータを暗号化する
ここからは、これまでの2つの暗号方式による暗号化、復号化を以下のメソッドで表現します。
# 共通鍵による暗号化
def symmetric_encrypt
# 共通鍵による復号化
def symmetric_decrypt
# 公開鍵による暗号化
def public_encrypt
# 秘密鍵による復号化
def private_decrypt
アリス(A)がボブ(B)との暗号化通信を行います。最初に、アリスが生成した共通鍵をボブに共有します。この時点ではボブの公開鍵を用いて暗号化します。
# アリスは共通鍵を生成する
symmetric_key, iv = SymmetricKey.gen
# アリスは「ボブの公開鍵で暗号化された共通鍵」をボブに送信する
ciphertext = public_encrypt(symmetric_key + iv, key: bob.public_key)
alice.send_message(ciphertext)
# ボブは受信した暗号化データを自分の秘密鍵で復号化する
ciphertext = bob.receive_message
symmetric_key, iv = private_decrypt(ciphertext, key: bob.private_key)
共通鍵が両者に共有されたので、以降は共通鍵暗号方式のみで通信します。
# アリスからボブに送信する
ciphertext = symmetric_encrypt('Hello Bob!', key: symmetric_key, iv: iv)
alice.send_message(ciphertext)
# 同様に、ボブからアリスに送信する
ciphertext = symmetric_encrypt('Hello Alice!', key: symmetric_key, iv: iv)
bob.send_message(ciphertext)
より発展的な実装
エンドツーエンド暗号化を安全に運用するには、悪意を持った第三者の攻撃に備える必要があります。
公開鍵が正しいものであることの認証
公開鍵をインターネット経由で受け取ると、第三者のなりすましにより偽の公開鍵を受け取る可能性があります。それを防ぐために、公開鍵のフィンガープリントを何らかの手段で直接受け取る等の対策が必要になります。例えば、お互いのスマートフォンでQRコードを用いてフィンガープリントを読み取る仕組みが考えられます。
メッセージの改ざんの検出
メッセージを暗号化するだけでは、第三者に内容を変更された場合にそれを検出することができません。メッセージの改ざんを検出する方法の1つに、AEAD暗号化方式があります。
鍵を盗まれた場合の対策
共通鍵、公開鍵、秘密鍵のどれか1つでも盗まれてしまうと第三者にメッセージを解読されることにつながります。Signalプロトコルでは、X3DH鍵交換やダブルラチェットセッションを用いて相互認証、前方秘匿性を提供することでこの脅威に対処しています。
参考にした情報