パスワードのリセット用URLや、APIキー。プログラムの中で自分で作った経験はありますか。
そのとき、randomを使っていなかったでしょうか。実はそこが、静かに危ない場所です。
Pythonには、秘密の値を作るための専用モジュールが最初から入っています。名前はそのまま、secretsです。
今回は、なぜrandomではいけないのか、secretsを使うと何が変わるのかを、動くコードと一緒に見ていきます。
標準ライブラリなので、インストールは必要ありません。今日書いているコードに、そのまま取り入れられます。
randomで作った文字列は、あとから当てられる¶
まず、randomの何が問題なのかという話をさせてください。
randomはメルセンヌ・ツイスタという方式で数を作っています。速くて質のよい乱数ですが、暗号のために設計されたものではありません。
この方式には、出てきた数をある程度集めると、次に出る数を計算できてしまう性質があります。つまり、過去に発行されたトークンをいくつか手に入れた人は、これから発行されるトークンを先回りして作れます。
公式ドキュメントにも、このモジュールの疑似乱数生成器をセキュリティ目的で使ってはいけない、とはっきり書かれています。代わりにsecretsを見るように、とも添えられています。
random自体の使い方は、こちらで詳しく扱いました。【関連記事】Pythonのrandomとは?サイコロやシャッフルの書き方と、パスワードに使ってはいけない理由を解説
secretsは、OSが持っている乱数をそのまま使う¶
secretsはPython 3.6で標準ライブラリに加わりました。PEP 506という提案がもとになっています。
仕組みはとてもシンプルです。Python側で数を作るのをやめて、OSが用意した乱数の仕組みに任せてしまいます。
LinuxやmacOSなら/dev/urandom、WindowsならOSの暗号機能が使われます。機器の細かな揺らぎなども材料にしているため、外から次の値を予測できません。
予測できないことが前提になっている。これがrandomとのいちばん大きな違いです。
randomとsecretsは、どちらを選ぶか¶
判断の基準はひとつだけです。その値を他人に当てられたら困るかどうか。
| 作りたいもの | 使うモジュール |
|---|---|
| サイコロ、シャッフル、テスト用のダミーデータ | random |
| 機械学習などで結果を再現したいとき | random(seedを固定できる) |
| パスワード、パスワードリセットのトークン | secrets |
| APIキー、セッションID、招待コード | secrets |
| ワンタイムパスワードや認証コード | secrets |
迷ったらsecretsを選んでおけば、まず事故になりません。ほんの少し遅いだけで、それ以外に困ることはないからです。
トークンは、たった1行で作れる¶
では実際に動かしてみましょう。よく使うのは、トークンを作る3つの関数です。
import secrets
print(secrets.token_bytes()) # bytesがそのまま返る
print(secrets.token_hex()) # 16進数の文字列
print(secrets.token_urlsafe()) # URLに埋め込める文字列
手元のPython 3.11で実行すると、次のような値が出ました。実行するたびに変わります。
b'\x8f\x1e...'
9f2c4a1d8b3e...(64文字)
qP3xR7k-bT9wZ...(43文字)
3つの違いは、返ってくる形だけです。中身のランダムさは、どれも同じだけあります。
| 関数 | 返ってくるもの | 向いている場所 |
|---|---|---|
token_bytes() |
bytes | 暗号鍵として別の処理へ渡す |
token_hex() |
0〜9とa〜fの文字列 | 設定ファイルやDBに保存する |
token_urlsafe() |
URLで使える文字列 | リセットURLやクエリ文字列 |
URLに埋め込むならtoken_urlsafeを選んでください。プラスやスラッシュが出てこないので、エンコードの手間がいりません。
token_hexのほうは、英数字だけで見た目がおとなしいのが利点です。ログに残しても文字化けせず、手で打ち直すときも迷いません。
何バイトを指定すればいいのか¶
引数を省略すると、32バイト分のランダムさが使われます。この32という数字は、secrets.DEFAULT_ENTROPYという定数で決まっています。
公式ドキュメントには、2015年の時点で32バイト(256ビット)あればこのモジュールの一般的な用途には十分だと考えられている、と書かれています。
つまり引数は省略してかまいません。短く指定すると弱くなるだけで、いいことはありません。
ひとつだけ注意があります。token_urlsafe(16)の16はバイト数であって、文字数ではありません。返ってくる文字列は22文字になります。
パスワードを作るなら、choiceを組み合わせる¶
人が手で入力するパスワードには、token_hexの文字列は長すぎることがあります。
そんなときはsecrets.choiceの出番です。使ってよい文字を並べたものから、1文字ずつ安全に選び出します。
import secrets
import string
ALPHABET = string.ascii_letters + string.digits
def make_password(length=12):
while True:
password = ''.join(secrets.choice(ALPHABET) for _ in range(length))
if not any(c.islower() for c in password):
continue
if not any(c.isupper() for c in password):
continue
if sum(c.isdigit() for c in password) < 3:
continue
return password
print(make_password()) # 例: 7aQ4mZt1Rk9X
whileで回しているのは、条件を満たすまで作り直すためです。足りない文字をあとから差し込むと、位置に偏りが出てしまいます。
この書き方は公式ドキュメントのレシピとして紹介されているものです。条件を変えたいときは、ifの中身だけを書き換えてください。
比べ方にも、落とし穴がある¶
作るところを直しても、まだ穴が残っています。受け取った値と正解を比べる場所です。
イコール2つで文字列を比べると、先頭から順に照合して、違った時点で処理が止まります。合っている文字が多いほど、判定にかかる時間がわずかに伸びるということです。
差は1秒の何百万分の1ほどです。それでも何万回も試せば、正解の文字を先頭から1文字ずつ割り出せてしまいます。
これを防ぐのがsecrets.compare_digestです。中身が違っていても最後まで比べるので、時間に差が出ません。
import os
import secrets
EXPECTED_KEY = os.environ['API_KEY'] # 正解のキーは環境変数から読む
def check_api_key(received):
return secrets.compare_digest(received, EXPECTED_KEY)
print(check_api_key('wrong-key')) # False
ひとつ注意点があります。compare_digestに日本語などの非ASCII文字を渡すとエラーになるので、キーは英数字で作ってください。
私は10年ほどエンジニアとして開発に関わってきましたが、このイコール2つの比較は、セキュリティ診断の指摘でいちばんよく見た項目のひとつです。動いてしまうので、レビューでも素通りしやすいのが厄介なところでした。
トークンは、作って終わりではない¶
予測できないトークンが作れるようになると、つい安心してしまいます。けれど運用の面では、もうひとつ考えることが残っています。
それは、いつまで有効にするかです。
パスワードリセットのリンクを例に考えてみましょう。メールで送ったリンクが半年後もそのまま使えるとしたら、どうでしょうか。
受信箱に残ったメールを見た誰かが、あとからアカウントを乗っ取れてしまいます。トークンの強さとは無関係に、時間が穴になるわけです。
期限と使い捨てをセットで考える¶
対策はむずかしくありません。発行した時刻を一緒に保存して、一定時間を過ぎたら無効にします。
さらに、1回使ったら消す。この2つを入れるだけで、漏れたときの被害がぐっと小さくなります。
私は10年ほどエンジニアとして開発に関わってきましたが、以前ここで痛い目を見ました。招待用のURLに期限を付け忘れ、退職した方の手元に残っていたリンクが半年後に使われて、慌てて全件を無効化したことがあります。
トークンの作り方は正しかったのに、です。安全な値を作ることと、安全に運用することは別の仕事だと、そのとき覚えました。
作った秘密は、置き場所まで面倒を見る¶
安全に作れたトークンも、置き場所を間違えれば意味がありません。
いちばんよくある失敗が、APIキーをコードに直接書いてGitHubへ上げてしまうことです。公開リポジトリなら、数分で拾われると思ってください。
秘密の値はコードの外に出して、環境変数から読み込みます。この手順はこちらで手を動かしながら確認できます。【関連記事】環境変数とは?PythonでAPIキーを安全に扱う.envと os.environ の基本を初心者向けに解説
利用者のパスワードを預かる場合は、そもそも元の文字列を保存してはいけません。ハッシュに変えてから保存します。【関連記事】Pythonのhashlibとは?パスワードをそのまま保存してはいけない理由とハッシュの基本を初心者向けに解説
つまずきやすいところ¶
最後に、初心者がひっかかりやすい点を3つ挙げておきます。
ひとつめは、自分のファイルにsecrets.pyという名前を付けてしまうこと。import secretsが自分のファイルを読みに行き、token_hexなど無いと言われて混乱します。標準ライブラリと同じ名前は避けてください。
ふたつめは、IDとトークンの取り違えです。uuidは重複しないことが目的、secretsは予測できないことが目的で、狙っているものが違います。当てられたら困る値にはsecretsを使いましょう。【関連記事】Pythonのuuidとは?重複しないIDを作る仕組みとPython 3.14で加わったuuid7を初心者向けに解説
みっつめは、速度です。secretsは毎回OSに問い合わせるぶん、randomより時間がかかります。
とはいえ、体感できるほどの差が出るのは何十万回と呼び出したときです。数百万件のダミーデータを作るような場面だけ、素直にrandomへ切り替えてください。
まとめと、次の一歩¶
secretsは、覚えることがとても少ないモジュールです。
秘密の値を作るときはtoken_urlsafeかtoken_hex、パスワードならchoice、比べるときはcompare_digest。当面はこれだけで足ります。
次の一歩として、今あなたが書いているコードの中でrandomを検索してみてください。その乱数が他人に知られて困るものなら、importを1行書き換えるだけで守りが変わります。
安全なコードは、難しい技術で守るものとは限りません。正しい道具を選ぶだけで済むことも、実はたくさんあります。
ここまでお読みいただきありがとうございました。