"BOKU"のITな日常

62歳・文系システムエンジニアの”BOKU”は日々勉強を楽しんでます

Pythonと暗号学的ハッシュ関数に関するあれこれ

今回はPythonで暗号学的ハッシュ関数を扱う話題です。

f:id:arakan_no_boku:20191120220124j:plain

 

暗号学的 ハッシュ関数とは

 

ハッシュ関数とは、ざっくり言えば、「あるデータを元に、それを代表する数値(要約値またはハッシュ値と言う)を得るための関数」です。

ハッシュ関数には、チェックサム、チェックディジット、フィンガープリント、誤り訂正符号、暗号学的ハッシュ関数などの種類があります。

どれも「同一性の確認に使う」点では同じなのですが、対象コンテンツとアルゴリズムが違います。

今回とりあげる、暗号学的ハッシュ関数は「パスワード」や「ブロックチェーン」や「デジタル証明書」などのセキュリティ確保に利用するもので、「任意の長さの入力を(通常は)固定長の出力に変換」します。

ja.wikipedia.org

 

Pythonの暗号学的ハッシュ関数ライブラリ

 

暗号学的ハッシュ関数は、よく使われるだけに、当然のように、Pythonでも標準で対応しています。

hashlibです。

docs.python.org

暗号学的ハッシュ関数には、様々な種類のアルゴリズムがあります。

それぞれ特徴があり、アウトプットの長さも様々です。

こちらの記事にも沢山紹介されてますが、TigerとかpanamaとかHAVALとか、一度も使ったことがないようなものも多いです。

ja.wikipedia.org

hashlibはそのうちの主要なもの・・SHA1、SHA224、SHA256、SHA384、SHA512 、MD5、BREAK2b、BREAK2s、shake128、shake256・・などのアルゴリズムに対応していて、簡単に使えるようになっています。

例えば「md5」なら。

import hashlib
m = hashlib.md5()
m.update(b"a")
print(m.hexdigest())

こんな感じで「a」1文字に対するハッシュ値が求められます。 

この結果は

0cc175b9c0f1b6a831c399e269772661

になります。

入力は1文字なのに、HEX(16進数)で32文字=128ビット長の結果です。

このへんが暗号学的ハッシュ関数の面白いところです。

  • 1文字の入力でも固定長128ビット(MD5の場合)。
  • 入力が数百メガバイトあるようなファイルが入力になっても固定長128ビット。

なんですね。

かつ。

同じアルゴリズムである限り、どんなツールを使っても、同じ入力に対しては同じハッシュ値が返ります。

なので、例えば、パスワードなどを平文ではなくハッシュ値で保存しておいて、画面入力時にハッシュ値を計算した結果と比較して、一致判断ができるわけですね。

 

レインボー攻撃(レインボーテーブル)

 

計算されたハッシュ値から元の値を復元するのは、まあ・・不可能です。

でも。

同じ入力で、同じアルゴリズムなら、同じハッシュ値が常に求められるというのを逆手にとった攻撃手段でハッシュ値の元になった平文を推測することは可能です。

やり方はシンプルです。

事前に、様々な平文をハッシュ関数に通してハッシュ値を計算して、そのハッシュ値をキーにして平文を返す逆引きテーブルみたいなものを用意すれば良いわけです。

こういう逆引きテーブルを「レインボーテーブル」と言い、それを使って、例えばパスワードをハッキングする攻撃(実際はもう少し複雑ですが)を「レインボーテーブル攻撃」と言います。

persol-tech-s.co.jp

この攻撃は、ある程度、事前に現れるであろう平文のパターンとアルゴリズムの組合せが予測できていないといけません。

なので、上記の記事では

今時「password」「abcdefg」「qwerty」などという単純すぎるパスワードは使っている人はいないと思うので(いませんよね?)、

 と書かれていて、なおかつ

レインボーテーブル攻撃が実際に使われることはほとんどありません。

なぜなら、「ハッシュ関数を多重化する」「ソルト(パスワードにキーワードを追加してからハッシュ関数にかける)」という方法で、レインボーテーブル攻撃を予防できるからです。

と書かれています。

自分もそのはずだ・・と思っていますが、どうも、現実はそうでないみたいです。

以下の記事によると、そういう単純すぎるパスワードを平気で使って、ハッキング被害にあっている人が千万人単位でいるみたいですからね。

10mtv.jp

なんともはや・・ですね。

 

暗号学的ハッシュで使う「ソルト」のイメージ

 

入力となる元文字列に少し手を加えるだけで、ハッシュ値は全く違うものになります。

だから、それをうまく使えば、レインボー攻撃を防ぐこともできます。

どんな感じか、やってみます。

例として。

まず、最悪のパスワードランキングの絶対王者(笑)「123456」をMD5ハッシュ値をもとめてみます。

import hashlib
m = hashlib.md5()
m.update(b"123456")
print(m.hexdigest())

求められたハッシュ値は。 

 e10adc3949ba59abbe56e057f20f883e

 です。

これに「a」を一文字足してみます。

m.update(b"a123456")

 これだけで、生成されるハッシュ値は以下のように変わります。

dc483e80a7a0bd9ef71d8cf973673924

全然違います。

かつ。

元のハッシュ値から推測できるような規則性もありません。 

こんな風に付け加えるランダムなデータ(上記例だとa)を「ソルト」と呼びます。

ja.wikipedia.org

実際には、「a」みたいな短いソルトは意味をなしません。

短いソルト、予測しやすい単純なパスワードであれば、事前にそれの組合せでハッシュ値をもとめておくことは容易にできますから。

なので、上記は、あくまで考え方の例にすぎません。 

適切なパスワードと、ある程度の長さを持つランダムなソルトを使うことが、大前提ではあります。 

 

ハッシュ関数を多重化するということ

 

ハッシュ関数を多重化するのもセキュリティ強度向上に有効な手として知られてます。

やり方は色々あります。

ですね。

例えば・・ということで、自分が知っている「一度求めたハッシュ値を、ソルトに使って」の例をひとつあげてみます。 

これは、アウトプット長を指定できるBreak2b、Break2sなどを使って求めたハッシュ値をソルトに使う例になります。

Break2bも、hashlibにあるので、以下のようにして使えます。

import hashlib
b2b = hashlib.blake2b(digest_size=5)
b2b.update(b"123456")
salt = b2b.hexdigest()
print(salt)
print(len(salt))

Break2bは、アウトプットサイズをバイト単位で指定できます。 

上記例だと「5バイト」指定なので、出力を16進数(4ビット単位)に変換すると、長さは「10」になります。

b934250400
10

こうして求める短いハッシュ値をソルトに使うわけです。

イメージとしては、こんな感じですかね。

import hashlib
b2b = hashlib.blake2b(digest_size=5)
b2b.update(b"123456")
salt = b2b.hexdigest()
sha256 = hashlib.sha256()
text = salt + "123456"
sha256.update(text.encode())
print(sha256.hexdigest())

Break2bで求めたハッシュ値を一度Hexの文字列にして、テキストに連結してから、Byte列に戻して、アウトプットのハッシュ値を計算しています。

まあ。 

数学的に考察したとかもなく、単純に固定のソルトをどっかに埋め込んでおくよりははマシだろう的な発想です。

こんなやり方もアリかな?・・的な一つの例でしかありません。

あしからず(笑)

 

ハッシュ関数アルゴリズムを強度の高いものにするのは基本中の基本です

 

あと。

あたりまえですけど、ハッシュ関数アルゴリズム自体も、できるだけ強度の高いものを選んでおくほうが安心です。

例えば、MD5SHA1については、限界や脆弱性が指摘されています。

www.ipa.go.jp

www.jpcert.or.jp

実際のところ。

そんな脆弱性をつかれて自分のところが攻撃にやられる・・なんて確率は低いです。

でも、もし発生したとき。

後付けでブーブー言われないよう、時点で問題ないと言われているアルゴリズムを選択しておいた方が良いと思います。

pythonなら、アルゴリズムの変更は簡単ですしね。

例えば、上記のMD5の例を安全と言われるSHA256に変更するなら。

import hashlib
m = hashlib.sha256()
m.update(b"a123456")
print(m.hexdigest())

「hashlib.md5()」の部分を「hashlib.sha256() 」に変更するだけです。

これで結果は

20f645c703944a0027acf6fad92ec465247842450605c5406b50676ff0dcd5ea

に変わります。

これだけで、脆弱性のあるアルゴリズムを使ったなどと、なんかあった時につつかれる可能性のある弱みが減らせるなら、やらない手はないと思ってます。 

 

現実的にそこまでの強度が必要な場合ばかりじゃない

 

直前までの話から、ちょっと方向が変わりますが、おまけです。

ハッシュの用途は様々なので、そこまでの強度が必要ない場合もままあります。

例えば。

内容的に価値の大したことの無い情報だけど、いちおう、改ざんチェック用にハッシュ値を持っておかないといけない・・みたいな時などです。

そんな時にがっつり本格的な「暗号学的ハッシュアルゴリズム」を使うのは、ちょっと重たいです。

もっと軽量で高速なアルゴリズムでもいいかな・・なんて思うわけです。

そんな時に使えるシンプルなアルゴリズムがあります。 

en.wikipedia.org

考案した3人( Glenn Fowler, Landon Curt Noll,  Kiem-Phong Voの名前をつなげただけの「非暗号化ハッシュ関数」です。

上記の記事を読めばわかりますが、とにかくシンプルです。

実際、記事の中に、疑似コードが書いてあって、ほぼ、それで実装できたりします。

たとえば、試しにやってみると、こんな感じ。

def fnv1a_32bit(inputs):
    FNV_prime = 16777619
    offset_basis = 2166136261
    uint32_max = 2 ** 32
    hash = offset_basis
    for b in inputs:
        hash = hash ^ b
        hash = (hash * FNV_prime) % uint32_max
    return hex(hash)[2:]


def fnv1a_64bit(inputs):
    FNV_prime = 1099511628211
    offset_basis = 14695981039346656037
    uint64_max = 2 ** 64
    hash = offset_basis
    for b in inputs:
        hash = hash ^ b
        hash = (hash * FNV_prime) % uint64_max
    return hex(hash)[2:]

この2つの関数は以下のように使います。 

print(fnv1a_64bit(b'hello world'))
print(fnv1a_32bit(b'hello world'))

これの結果です。

779a65e7023cd2e7
d58b3fa7

こういうのは。

無理に使うようなものではありません。

でも。

引き出しにしまってある知識のひとつとして「こんなのもあるよ」って持っていても、悪くはないかな・・とも思ってます。 

さて。。

今回は、こんなものですかね。

ではでは。