Pythonの学習が進んで、ログイン機能を自分で作ってみようという段階に来ると、多くの人が同じところで手を止めます。ユーザーが登録したパスワードを、どうやって保存すればいいのかという問題です。
データベースの列にそのまま入れれば動きはします。動くのに、なんとなく気持ち悪い。その感覚は正しいものです。
このもやもやを解消してくれるのが、Pythonに最初から入っているhashlibというモジュールです。この記事では、ハッシュとは何かというところから、実務で通用する保存方法までを順番に見ていきます。
パスワードをそのまま保存すると、何が起きるのか¶
まず、何がまずいのかをはっきりさせておきます。パスワードをそのままの文字で保存することを、平文保存と呼びます。
平文保存の怖さは、データベースが一度でも外に漏れた瞬間に全部が終わることです。攻撃者は解読の手間すらかけずに、全ユーザーのパスワードをそのまま手に入れます。
そしてもっと厄介なのが、被害がそのサービスの中で止まらないことです。多くの人は複数のサービスで同じパスワードを使い回しているからです。
私は10年ほどエンジニアとして開発に関わってきましたが、古いシステムを引き継いだときに、パスワードの列に読める文字がそのまま並んでいるのを見たことがあります。あのときの背筋が冷える感じは、いまでもよく覚えています。
だから必要になるのが、元に戻せない形へ変えてから保存するという発想です。その変換を担うのがハッシュ関数になります。
ハッシュは、戻せない一方通行の変換¶
ハッシュ関数というのは、どんな長さのデータを渡しても、決まった長さの値を返してくれる関数のことです。返ってきた値のほうはハッシュ値と呼びます。
いちばん大事な性質は、逆向きに計算できないことです。ハッシュ値から元のデータを取り出す方法は用意されていません。
この一方通行という性質があるおかげで、パスワードそのものを持たずに、パスワードが合っているかどうかだけを確かめられます。
同じ入力なら、いつでも同じ結果になる¶
ハッシュ関数は、同じデータを渡せば何度でも同じ値を返します。実行するたびに変わったりはしません。
だからログインのときは、入力されたパスワードを同じ手順でハッシュ化して、保存してある値と一致するかを見ればいいわけです。
1文字違うだけで、まったく別の値になる¶
もうひとつの特徴が、入力を少しだけ変えると結果がまるごと変わることです。pythonとPythonの結果を並べてみると、その激しさがよくわかります。
似ている入力から似ている出力が出てしまうと、そこを手がかりに元の値を推測されてしまいます。だから、あえて似ないように設計されています。
hashlibを使って、実際に手を動かしてみる¶
前置きが長くなりました。追加のインストールは要らないので、そのまま試せます。
hashlibは標準ライブラリなので、importするだけで使えます。まずは定番のSHA-256を動かしてみましょう。
import hashlib
# 文字列はそのままでは渡せないので、bytes に変換する
data = "python".encode("utf-8")
digest = hashlib.sha256(data).hexdigest()
print(digest)
# 11a4a60b518bf24989d481468076e5d5982884626aed9faeb35b8576fcd223e1
# 先頭を大文字にしただけで、結果はまったくの別物になる
print(hashlib.sha256("Python".encode("utf-8")).hexdigest())
# 18885f27b5af9012df19e496460f9294d5ab76128824c6f993787004f6d9a7db
print(len(digest)) # 64(16進数の文字が64個 = 32バイト)
hexdigest()は結果を16進数の文字列で返します。バイト列のまま欲しいときはdigest()を使います。
SHA-256はどんなに長い文章を渡しても、必ず32バイトの値を返します。上の例で長さが64になっているのは、1バイトを16進数2文字で表しているからです。
文字列をそのまま渡すとエラーになる¶
初めて触ったとき、ほぼ全員がここでつまずきます。hashlib.sha256("python")と書くと、TypeErrorで止まります。
ハッシュ関数が受け取れるのはバイト列だけだからです。文字をどういうルールでバイトに直すかを、こちら側で決める必要があります。
日本語を含む文字列では、encodeに指定する文字コードが違うだけでハッシュ値も変わります。UTF-8で統一しておくのが無難です。
このあたりの仕組みがまだあやふやなら、先に整理しておくと理解が一段速くなります。【関連記事】Pythonの文字コードとは?文字化けとUnicodeDecodeErrorの直し方を初心者向けに解説
どのアルゴリズムを選べばいいのか¶
hashlibにはいくつもの関数が入っています。公式ドキュメントによると、どの環境でも必ず使えるものとして、md5、sha1、sha256、sha3系、blake2などが挙げられています。
名前が多くて迷いますが、実際に選ぶ基準はそれほど複雑ではありません。よく出てくるものを表にまとめました。
| 名前 | ハッシュ値の長さ | いまの立ち位置 |
|---|---|---|
md5 |
16バイト | 衝突の弱点が知られている。安全性が要る場面では使わない |
sha1 |
20バイト | 同じく弱点が知られている。過去との互換のためだけに残っている |
sha256 |
32バイト | 現在の標準的な選択肢。迷ったらこれ |
sha512 |
64バイト | SHA-256と同系統で、より長い値が欲しいとき |
blake2b |
最大64バイト | 比較的新しく、長さを自由に決められる |
公式ドキュメントは、MD5とSHA-1について衝突の弱点が知られていると明記しています。衝突というのは、違うデータから同じハッシュ値が出てしまう状態のことです。
MD5を見かけても、すぐ危険というわけではありません。ファイルの取り違えを防ぐだけの用途なら、いまも現役で使われています。
ファイルまるごとのハッシュはfile_digestが便利¶
ハッシュのもうひとつの定番の使い道が、ファイルが途中で壊れていないかの確認です。配布元が公開しているハッシュ値と、手元で計算した値を比べます。
大きなファイルを一度にメモリへ読み込むと、それだけでメモリが足りなくなります。そこでPython 3.11からfile_digest()という関数が追加されました。
import hashlib
# バイナリモード("rb")で開くのがポイント
with open("sample.zip", "rb") as f:
digest = hashlib.file_digest(f, "sha256")
print(digest.hexdigest())
分割して読み込む処理を自分で書かなくてよくなったのが、この関数のうれしいところです。私も以前は毎回同じようなループを書いていました。
ファイルを開くときのモード指定に不安があるなら、基本を押さえておくと安心です。【関連記事】Pythonのファイル読み書きとは?open()とwith文の基本を初心者向けに解説
それでもSHA-256でパスワードを守れない理由¶
ここが、この記事でいちばん伝えたいところです。SHA-256は優秀なハッシュ関数ですが、パスワードの保存には向いていません。
理由は意外に思えるかもしれません。速すぎるからです。
手元の環境でSHA-256を1000回計算したところ、かかった時間は1ミリ秒に届きませんでした。速いということは、攻撃する側が1秒間に膨大な数の候補を試せるということでもあります。
さらに、よく使われるパスワードのハッシュ値はあらかじめ表にまとめられています。この一覧はレインボーテーブルと呼ばれ、有名な文字列なら計算すらせずに逆引きされてしまいます。
ソルトを混ぜると、使い回しが効かなくなる¶
この対策として使うのが、ソルトと呼ばれるランダムな値です。パスワードにソルトを混ぜてからハッシュ化し、ソルトも一緒に保存します。
ユーザーごとに違うソルトを使えば、同じパスワードでも保存される値は別々になります。攻撃者は事前に用意した表を使えなくなり、1人ずつ計算し直すはめになります。
ソルトを作るときは、randomモジュールではなくsecretsモジュールを使ってください。推測されにくい値が必要な場面で通常の乱数を使うのは、実務でよくある危険な間違いです。
その違いについては、こちらで詳しく扱っています。【関連記事】Pythonのrandomとは?サイコロやシャッフルの書き方と、パスワードに使ってはいけない理由を解説
パスワード保存には、わざと遅い関数を使う¶
では何を使えばいいのか。hashlibには、この用途のために用意された関数がちゃんと入っています。
公式ドキュメントは、sha1(password)のような素朴な方法は総当たり攻撃に耐えられないと書いています。そのうえで、良いパスワードハッシュ関数は調整可能で、遅く、ソルトを含むべきだと説明しています。
遅いことが利点になるのは、パスワードの世界だけかもしれません。ログイン1回に0.2秒かかっても人間は気づきませんが、攻撃側の総当たりは何十万倍も遅くなるからです。
代表的な関数がpbkdf2_hmac()とscrypt()です。実際に書いてみます。
import hashlib
import secrets
def hash_password(password: str) -> tuple[bytes, bytes]:
# ソルトは推測されない乱数から作る(16バイト以上が目安)
salt = secrets.token_bytes(16)
key = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
600_000, # 繰り返し回数。多いほど遅く、そのぶん安全になる
)
return salt, key
def verify(password: str, salt: bytes, expected: bytes) -> bool:
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 600_000)
return secrets.compare_digest(key, expected)
salt, key = hash_password("correct horse battery staple")
print(verify("correct horse battery staple", salt, key)) # True
print(verify("wrong password", salt, key)) # False
繰り返し回数の600,000という数字には根拠があります。OWASPのPassword Storage Cheat Sheetが、PBKDF2-HMAC-SHA256の推奨値として挙げている数です。
同じ資料は、選べるならArgon2idを第一候補とし、それが使えない場合はscryptを勧めています。PBKDF2は、FIPS-140への準拠が求められる場面で選ぶ位置づけです。
Argon2idは標準ライブラリには入っていないので、使うなら外部ライブラリが必要になります。まず標準の範囲で安全に作りたいなら、pbkdf2_hmac()かscrypt()が現実的な選択です。
保存先の設計まで含めて手を動かしてみたい方は、まず小さなデータベースで試すとつかみやすくなります。【関連記事】Pythonからデータベースを操作するsqlite3の使い方を解説
照合するときは、==を使わない¶
最後に、見落とされがちな作法をひとつ紹介します。上のコードで==ではなくcompare_digest()を使ったのには理由があります。
==による比較は、違いが見つかった時点で処理を打ち切ります。つまり、先頭がどこまで一致していたかが、わずかな時間の差としてにじみ出るわけです。
この差を何万回も測って正解を1文字ずつ絞り込む手口を、タイミング攻撃と呼びます。compare_digest()は結果によって処理時間が変わらないよう作られているため、この手がかりを与えません。
秘密の値を比べるときはcompare_digest()を使う、と覚えておけば十分です。パスワードだけでなく、APIキーやトークンの照合でも同じ考え方が当てはまります。
なお、APIキーそのものをコードに書き込まない工夫も合わせて必要です。【関連記事】環境変数とは?PythonでAPIキーを安全に扱う.envと os.environ の基本を初心者向けに解説
まとめ¶
hashlibは、データを元に戻せない値へ変換するための標準ライブラリです。SHA-256を使えば、数行でハッシュ値を計算できます。
ただし用途によって、選ぶ関数が変わります。ファイルの同一性チェックならSHA-256、パスワードの保存ならpbkdf2_hmac()やscrypt()という使い分けです。
そして、ソルトを付けること、照合にcompare_digest()を使うこと。この2つを添えるだけで、安全性はぐっと上がります。
まずは手元で自分の名前をSHA-256にかけて、1文字変えたときの変わりようを眺めてみてください。ハッシュという道具の感触が、ぐっとつかみやすくなるはずです。
参考情報¶
- hashlib — Secure hashes and message digests(Python公式ドキュメント)
- secrets — Generate secure random numbers for managing secrets(Python公式ドキュメント)
- Password Storage Cheat Sheet(OWASP Cheat Sheet Series)
ここまでお読みいただきありがとうございました。