暗号化でセキュアなデータ交換

インターネットなどの公衆回線を通じてデータを交換する場合、避けて通れないのは暗号化です.
メジャーな実装として、共通鍵 / 公開鍵暗号がありますが、実際にどんな風になるのか見てみます.


インターネットは公衆回線

インターネットは公衆回線です。 誰でもネットワークに接続できますし、通信する時の経路がどのようになっているのかは保証されていません。 そこで安全にデータをやりとりしたり、交換したデータの正しさを保証するための方法が考えられてきました。

ネットワークのプロトコルレベルでは、経路の暗号化として TLS/SSLHTTPS が使われます。 経路とはネットワークの入り口から出口までで HTTPS の場合は、ブラウザから WEB サーバまでです。

SSL や HTTPS の脆弱性は置いておいても、暗号化されるのは経路上だけです。 どういうことかというと、宅配便でなにかを送る時に箱に入れて送ることを想像してみてください。 宅配便で運ばれている時には、その箱になにが入っているか、他の人にはわかりません。 経路の保護は宅配便と同じで、送付元から宛先までの間は封をした箱に入れて送ることで、中身の秘密が保護されています。

しかしながら、箱が誰かに横取りされたり (宛先の玄関で偽物が待ち構えてたり、実は宅配業者が悪者で配達されなかったりとか)、 届いた箱を受け取った後 (サーバがクラックされたり) や、送る前 (ブラウザにスパイウェアが仕込まれてたり) に盗み見されたりすれば、中身がバレてしまいます。

本気で中身を秘密にするには、守りたいものを金庫に入れて、それを宅配便で送ると良いでしょう。 また、届いた後も必要になるまで金庫を開けないことにすれば、もっとセキュアになります。 要は、サイバーセキュリティ対策も実生活で経験していることを応用すればいいのです。

ただし、ソフトウェアでは金庫や鍵を複製することは実物よりもはるかに楽だったり、なにかを入れた金庫ごとコピーできちゃったりしますので、その辺のコストにより実装の考え方が異なります。(実社会では、鍵を複製するために、現物を手に入れてそれを型取ってとか色々と手間がかかります。 IT の世界ではちょっとの隙を見つけて鍵のデータをコピーするだけです。 しかも鍵に触れたことも分からない様に抜き取ったりできたりします)

データの暗号化方式

ネットワークは安全じゃないという前提では、やり取りするデータそのものを暗号化して、安全性を担保する必要があります。

データを保護する暗号化の方法 (金庫の使い方) として、今は以下の2つの方法が主流です。

  • 共通鍵暗号 - 暗号と復号を同じ鍵
  • 公開鍵暗号 - 暗号と複合は違う鍵

共通鍵暗号は、1つの金庫の同じ鍵を、送り手と受け手の両方で持つのと同じです。 それに対して公開鍵暗号は、受け手が持っている鍵で開けられる金庫をみんなに配るようなものです。(厳密には違いますが、概念としてはそんな感じ)

現実には、パスワード付きのアーカイブをメールに添付してとかやったりしているかもしれませんが、それはダイアルロックのブリーフケースと同じ程度なので、セキュリティとも言えないと思います (繰り返しますが、ソフトウェアの世界では入れ物ごと気づかれずに簡単にコピーできちゃうというのを忘れないで下さい)

共通鍵暗号は、同じ鍵を使う必要があるため、からず鍵の受け渡しが必要です。 知り合い同士のデータ交換ならあまり問題になりませんが (鍵を直接会って交換するとか、ネットワークを経由しない方法を使う)、不特定多数とデータを交換する必要がある場合、鍵交換時の安全を担保する方法が問題になります。

公開鍵暗号は、公開鍵と秘密鍵の2つの鍵を用意します。 鍵には、公開鍵で暗号化したデータは、対応する秘密鍵でないと復号できないという特性があります。 ただし、公開鍵暗号は、共通鍵暗号と比べて、データの暗号、復号に時間がかかります。 大きなデータを受け渡したり、多くのユーザをサポートするシステムでは課題となります。

そこで、データを交換する際の実装としては、それらを組み合わせて使います。 一時的な鍵を生成して共通鍵暗号でデータの暗号化します。 次に受け手の公開鍵を使って、その鍵だけを暗号化します。 暗号化された実データと鍵データを受け手に送ります。

受け手側は、秘密鍵を使って鍵データの復号をして、その鍵で実データを復号します。 そうすることで、鍵交換時の問題を解決しつつ、パフォーマンスを担保しながらのデータ交換が可能になります。 また、途中で誰かにデータを盗まれたとしても、秘密鍵がないと鍵データは復号ができませんので、暗号化されたデータの保護にはあまり気を使わなくてもよくなります。 (暗号化されたデータが漏れた時の危険性は、共通鍵暗号を破ることができるのと同程度の危険性ということになるので、現実には問題にしなくても良いかと)

データ交換の実装

それでは、実際にセキュアなデータ交換の実装はどんな感じか見てみたいと思います。 ここでの想定は、秘密鍵を持った人に、セキュアにデータを送付するシステムです。 データをセキュアに保存するとかにも応用できると思います。

概要としては、送り側で共通鍵暗号方式 AES で実データを暗号化します。 その鍵データを公開鍵暗号方式 RSA で暗号化し、送付します。

受け側では、鍵データを RSA の秘密鍵で復号し、その鍵を使って AES で実データを復号するという流れになります。

どのような暗号化方式を組み合わせるのが良いのか等は、システムの想定によって変わるかと思います。

openssl による RSA 鍵の作成

公開鍵暗号の RSA の鍵データですが、今回は openssl コマンドで生成します。 (開発を MacOS で行っているので、簡単に使える方法ってことで)

$ openssl genrsa 2048 > rsa_private.pem

genrsa コマンドは RSA の鍵生成で、 2048 bitの長さの鍵を生成します。 鍵の長さをどうするかは常に課題になります。短すぎればセキュリティが懸念されますし、長すぎると計算の時間、交換するデータの長さに影響がでます。 (今のところ、2048bit の RSA暗号は計算にかかる時間から、まあ安全とされているレベルです)

結果は rsa_private.pem というファイルに保存します。 正しく作成されているかの確認は、次のコマンドで確認できます。

$ openssl rsa -text < rsa_private.pem 

秘密鍵なのに、中身が読めちゃって良いのかってのがあるかと思います。 秘密鍵の名前の通り、鍵そのものをきちんと秘匿しておかないと、そこがセキュリティ上のリスクになります。 今回は、暗号化周りの実装をテストすることが目的なので、その辺は無視していますので注意してください。 実際の運用ではパスワードをかけるとかセキュアストレッジ等での運用などの対策が必要です。

次に、秘密鍵から対となる公開鍵を生成します。

$ openssl rsa -pubout < rsa_private.pem > public.key

こちらも中身を確認できます。

$ openssl rsa -text -pubin < public.key

秘密鍵と公開鍵のModulusExponentの値は同じになっているはずです。

ちなみにですが、RSA暗号 というのは、2つの大きな素数pqの積nが分かっても、元の素数pqを計算するには、とんでもない時間がかかるってのが安全性の根拠です。 (学校でならう素因数分解ってやつですね。鍵の長さ=桁が大きくなると比例して計算が大変になります)

公開鍵の中のModulusというは、nのことです。 nを公開してしまっても、元のpqは求められないってことですね。 nを使ってゴニョゴニョしたものは、 pqを知ってる人だけ元に戻せますよってのが、 RSA暗号です。 (n は金庫で、pqが鍵ってことになります)

データと鍵の暗号化の実装

今回は、 Python で実装してみます。 完全なソースコードは、以下に置いてあります。

Github - secure_data

送り側の実装は、 sender_encrypt.py です。

import とか宣言は飛ばして、キモになるところだけ見ていきましょう。

元のテストデータ (test_data.jpg) を読み込みます。

with open ('test_data.jpg', 'rb') as f:
    data = f.read()

m = hashlib.sha256()
m.update(data)
print(m.hexdigest())

hashlib.sha256 は、 SHA-256 ハッシュ関数で、ある特定のアルゴリズムで、与えられたデータの 256bit のハッシュ値を計算します。 (ハッシュなに? とかはそのうち)

読み込んだデータのハッシュ値を出力します。 復号後のハッシュ値と比較することできちんと復号できたか確認できます。

b6ee0d4f499cd26f44fcc916d91e4ca2ff78c5d17d93b46d2ff6f94aae4b0b1e

AES の共通鍵を準備

次に AES の鍵データを生成します。

暗号化する元データに現在時刻を付加しておきます。これは実行毎に結果がランダムになるようにするためと、実行時刻を記録するためです。

ct = datetime.datetime.now()
print(ct)
cti = int(ct.timestamp() * 1000 * 1000)
print(cti)
time_val = cti.to_bytes(8, 'big')

data = f_ver + time_val + data
m = hashlib.sha256()
m.update(data)

データのハッシュ値は、 m.digtest() でアクセスできますので、これを AES の鍵データとして使います。

AES でデータを暗号化

できあがった鍵を使って元データを AES 暗号化します。 まずは暗号器の準備です。

cipher = AES.new(m.digest(), AES.MODE_CBC)

AES.MODE_CBC は、AES 暗号時のオプションで、 Cipher Block Chainging モードになります。 暗号時に直前ブロックの暗号化済データと XOR されます。 そうすることで、暗号化をする時に、前後の関係性によって暗号が強化されます。 (前のブロックに依存するようになるので、暗号化済データが先頭から全て揃わないと復号できなくなるって感じです)

鍵データと元データの準備ができたので、 encrypt で暗号化します。

ct_bytes = cipher.encrypt(pad(data, AES.block_size))

AESは固定長のブロック暗号です。 pad() で半端な長さのブロックを補完してあげます。

暗号化されたデータは、 ct_bytes に格納されていますので、データを書き出します。

with open ('crypto_data.jpg', 'wb') as f:
    f.write(ct_bytes)

AES 鍵データを RSA 暗号化

最後に、AES暗号で使用した共通鍵データを、RSA公開鍵で暗号化します。

鍵データと、IV (Initial Vector) と呼ばれる AES で使用する初期設定値を base64 でエンコードします。

key = urlsafe_b64encode(m.digest()).decode('utf-8')
print(key)
iv = urlsafe_b64encode(cipher.iv).decode('utf-8')
print(iv)
message = json.dumps({'k':key,'v':iv})

出来上がったものを、RSAで暗号化します。

with open('public.pem', 'br') as f:
    public_pem = f.read()
    public_key = RSA.import_key(public_pem)

public_cipher = PKCS1_OAEP.new(public_key)
ciphertext = public_cipher.encrypt(message.encode())

暗号化された鍵データを data.txt に書き出して終わりです。

with open ('data.txt', 'wb') as f:
    f.write(urlsafe_b64encode(ciphertext))

本来ここで、暗号化された実データ(crypto_data.jpg)と鍵データ(data.txt)を受け手側に送信します。

受け手側でのデータ復号の実装

受け側は、 receiver_decrypt.py になります。

鍵データの復号

最初に、鍵データを復号します。

private.pem に、秘密鍵が保存されている前提になります。

with open('private.pem', 'rb') as f:
    private_pem = f.read()
    private_key = RSA.import_key(private_pem)

送り手からもらった暗号化された鍵データ (data.txt) を、秘密鍵で復号します。 暗号側と逆の手順です。

with open ('data.txt', 'rt') as f:
    data = urlsafe_b64decode(f.read())

private_cipher = PKCS1_OAEP.new(private_key)
message = private_cipher.decrypt(data).decode("utf-8")
print(message)

AES復号の準備

複合されたデータの中身から、AES の鍵 と IV (Initial Vector) を取り出します。 送り側で、base64encode しているので、decode してから使います。

config = json.loads(message)

key = urlsafe_b64decode(config['k'])
iv = urlsafe_b64decode(config['v'])
cipher = AES.new(key, AES.MODE_CBC, iv)

復号側の実装は、暗号側の逆手順です。

AESの復号実行

準備ができたので、暗号化された実データを復号してあげます。

with open ('crypto_data.jpg', 'rb') as f:
    data = f.read()

暗号化する際に、固定長にするために、 pad してますので、 unpad で余分なデータを取り除きます。

try:
    pt = unpad(cipher.decrypt(data), AES.block_size)

データには、暗号化する時に、実行時間を付加していますので、それを取り除いてから、 output.jpg として保存します。

    ver = pt[:1]
    print('ver: {}'.format(ver[0]))

    dt = pt[1:9]
    dti = int.from_bytes(dt, byteorder='big')
    dtn = datetime.datetime.fromtimestamp(dti / (1000 * 1000))
    print(dtn)

    with open ('output.jpg', 'wb') as f:
        f.write(pt[9:])

残りの部分はきちんと復号できたかの確認用になります。保存したデータの SHA256 を求めています。 暗号前のハッシュ値と比較して同じ値になっていれば、正しく復号できています。

    m = hashlib.sha256()
    m.update(pt[9:])
    print(m.hexdigest())

最後にソースコード Github - secure_data の方、実際に実行するとどうなるかだけ、載せておきます。

$ python sender_encrypt.py
b6ee0d4f499cd26f44fcc916d91e4ca2ff78c5d17d93b46d2ff6f94aae4b0b1e
2021-07-27 14:11:35.743782
1627362695743782
{"k": "nQNbd-UdupgniPRHyQgTi7bGSfwrro8iCyPPEZXnCpo=", "v": "j9K7-oZ8vFaGYZEicV-MoQ=="}
$ python receiver_decrypt.py
{"k": "nQNbd-UdupgniPRHyQgTi7bGSfwrro8iCyPPEZXnCpo=", "v": "j9K7-oZ8vFaGYZEicV-MoQ=="}
ver: 1
2021-07-27 14:11:35.743782
b6ee0d4f499cd26f44fcc916d91e4ca2ff78c5d17d93b46d2ff6f94aae4b0b1e

encrypt の逆が decrypt 側に出力されるはずです。 テスト用のデータを変更して、データ交換がうまくいくか試してみてください。

まとめ

セキュアなデータ交換の方法として、公開鍵暗号と共通鍵暗号を組み合わせてみるのを試してみました。

暗号化が絡む実装する時にハマりやすいのが、それぞれの手順のパラメータを合わせることです。 ネットワーク経由でデータ交換などを考えると、それに文字コード変換や base64 等によるデータ変換が絡んできます。 暗号と復号は、基本的に逆手順で行うことになりますが、それぞれの段階で正しくリバースできているか確認しながら進めることが大事でしょう。

また、今回は両方とも python で同じ OS 上でやっていますが、クライアントがスマホのネイティブコード (java/kotlin や swift) の場合、 実装の差異 (サポートされている暗号化方式やモード) やパラメータの違いがあったりして、その辺もハマりどころになるので注意してください。