安全なトークン生成:random と secrets の違いを実測で
Python の random を出力624個から約1ミリ秒で復元し、次のパスワード再設定URLを的中させる実験と、secrets・random_bytes・UUIDv4・時刻シードの危険、トークン長と推測時間の表を実測でまとめる。
シリーズ:セキュリティ基礎講座 3/6(中級)。 パスワードのハッシュ化(第2回)はできたけれど、次はセッション ID、パスワード再設定 URL、API キー、招待コードを発行する、という開発者向けの記事です。読み終えると、Python と PHP でどの乱数関数を呼べばよいか、トークンが何ビット必要か、「ランダムに見える」が何の証明にもならない理由を説明できるようになります。
パスワード再設定メールの URL に入っているトークンは、有効期限の15分間だけは「パスワードそのもの」です。過去の漏えい事故の報道では「パスワードを平文で保存していた」がよく話題になりますが、再設定トークンを推測できる作りも同じくらい危険で、アカウントを乗っ取られれば個人情報保護法上の漏えい等の報告が必要になる事態にもつながりえます(一般論です。個別の判断は専門家へ)。原因はたいてい1つ、暗号用の乱数が必要な場所で汎用の乱数を使ったことです。この記事では、わざとそう作った自前のおもちゃアプリを手元で破り、代わりの方法をすべて計測し、最後にルールをまとめます。実行環境は Python 3.9.6、PHP 8.5.6、OpenSSL 4.1.0、Apple Silicon Mac(M1 Pro)。シードが関係する箇所は固定しています。攻撃はすべて、このマシン上で自分が作ったコードとデータだけに対して行いました。
ハッシュと MAC の違いがまだあやふやなら、先に第1回をどうぞ。このシリーズはプログラミング学習ロードマップの一部です。
random と secrets は別の道具
Python には見た目の似た乱数 API が2つあり、役割はまったく違います。
| モジュール | アルゴリズム | シード | 用途 |
|---|---|---|---|
random |
メルセンヌ・ツイスタ(MT19937)、内部状態は32ビット×624語 | 引数なしの random.seed() なら os.urandom、または渡した値 |
シミュレーション、ゲーム、テストデータのシャッフル、再現できる実行 |
secrets(3.6〜) |
独自アルゴリズムなし。OS の CSPRNG を読む | カーネル | トークン、鍵、ソルト、再設定コードなど、推測されると困るものすべて |
CSPRNG(暗号論的に安全な疑似乱数生成器)には「出力をいくら見ても次の出力を予測できない」という約束が1つ多くあります。MT19937 にはその約束がありません。統計的にはとても良い乱数ですが、予測不能ではないのです。
import random, secrets
print(secrets.token_hex(16)) # 16進32文字 = 128ビット
print(secrets.token_urlsafe(16)) # URL で使える約22文字 = 128ビット
print(secrets.DEFAULT_ENTROPY) # 長さを省略したときのバイト数
try:
random.SystemRandom().getstate()
except Exception as e:
print(type(e).__name__, e)
e6a825116541f1d1a20030e60398aa48
CBomwXz1dT6i6kcAXQqszA
32
NotImplementedError System entropy source does not have state.
最後の1行が CSPRNG の本質です。プロセスの中に、誰かが写し取れる状態がそもそもありません。
よくある間違い:random で再設定URL → クローンが的中 → 対策
いちばんよくある形です。おもちゃアプリが1つの random.Random を2つの用途に使っています。API の応答に出す公開のリクエスト ID(16進8文字。それ自体は無害)と、パスワード再設定 URL のトークンです。リクエスト ID を連続して624個集めた観察者は、生成器を作り直せます。
理由はこうです。MT19937 の内部状態は624語。32ビットの出力は、状態の1語を「テンパリング」という4段のシフトと XOR に通しただけです。どの段も逆算できるので、出力1個から状態の1語がそのまま戻ります。624個集めれば状態のすべてがそろい、Python 自身の setstate() でクローンに読み込めます。
# mt_reset_ja.py - 手元だけのデモ:random の状態を出力624個から復元する
import random
def unshift_right(y, shift):
result = y
for _ in range(32 // shift + 1):
result = y ^ (result >> shift)
return result & 0xFFFFFFFF
def unshift_left(y, shift, mask):
result = y
for _ in range(32 // shift + 1):
result = y ^ ((result << shift) & mask)
return result & 0xFFFFFFFF
def untemper(y): # テンパリング4段を逆順に戻す
y = unshift_right(y, 18)
y = unshift_left(y, 15, 0xEFC60000)
y = unshift_left(y, 7, 0x9D2C5680)
y = unshift_right(y, 11)
return y
def issue(r): # パスワード再設定 URL を発行
return f"https://example.com/reset?t={r.getrandbits(64):016x}"
victim = random.Random(1114) # デモ用の固定シード
observed = [victim.getrandbits(32) for _ in range(624)] # 公開リクエストID
real = [issue(victim) for _ in range(3)] # 他人宛ての再設定URL
clone = random.Random()
clone.setstate((3, tuple(untemper(v) for v in observed) + (624,), None))
guess = [issue(clone) for _ in range(3)]
for a, b in zip(real, guess):
print(a, "|", b, "MATCH" if a == b else "differ")
https://example.com/reset?t=1951e71e80bba42c | https://example.com/reset?t=1951e71e80bba42c MATCH
https://example.com/reset?t=fc4f9bff1a945bd5 | https://example.com/reset?t=fc4f9bff1a945bd5 MATCH
https://example.com/reset?t=de62e6aa465e593e | https://example.com/reset?t=de62e6aa465e593e MATCH
状態の復元にかかった時間は中央値 1.08 ミリ秒(100回×5セット)でした。(3, state, None) は CPython 3.9 の getstate() が返す形式(バージョン3、624語+位置、ガウス分布のキャッシュなし)です。長さを間違えると ValueError: state vector is the wrong size になります。
よく聞く3つの言い訳と、その答えです。
- 「getrandbits じゃなくて randrange しか使っていない」:状態を手に入れたクローンは、その後の呼び出しを
randrange・choice・shuffleも含めてすべて再現します。どれも同じ32ビットの流れから取り出しているからです。見える出力が切り詰められていると状態を集めるのは難しくなりますが、その方法も研究として公開されています。「攻撃者に見えるビットが少ない」を安全の根拠にしないでください。 - 「シードは os.urandom から取った」:関係ありません。シードは最初の状態を決めるだけで、出力からは状態が漏れ続けます。
- 「624個も集める人はいない」:アクセスの多い API なら数秒で出ます。
対策: 秘密になる値はすべて secrets、random はシミュレーション専用。
import secrets
session_id = secrets.token_urlsafe(32) # 256ビット、Cookie に入れられる
reset_token = secrets.token_urlsafe(32) # URL に入れ、DB にはハッシュだけ保存
api_key = "sk_" + secrets.token_hex(32)
six_digits = f"{secrets.randbelow(10**6):06d}" # ワンタイムコード:試行回数制限とセットで
ルール: 当てられると攻撃者が得をする値は OS の CSPRNG から取る。自分でシードした生成器は使わない。
PHP とほかの言語
PHP も同じ二本立てです。mt_rand() と rand()(PHP 7.1 から mt_rand の別名)はメルセンヌ・ツイスタ。random_bytes() と random_int()(PHP 7.0〜)は OS の CSPRNG を読みます。PHP 8.2 で Random\Randomizer クラスが入り、既定のエンジンは Random\Engine\Secure です。
<?php
echo bin2hex(random_bytes(16)), "\n"; // 128ビットの16進トークン
echo rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '='), "\n"; // URL セーフ
echo str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT), "\n"; // ワンタイムコード
mt_srand(1114); $a = mt_rand(); mt_srand(1114); $b = mt_rand();
echo "mt_rand same seed: $a $b\n";
$s = new Random\Randomizer();
echo get_class($s->engine), "\n";
echo uniqid(), "\n", uniqid(), "\n";
cd11d73e54d4712cfa62290971d93ec7
a6OmaIUvgPVddjIEO3rPkA
473973
mt_rand same seed: 324694876 324694876
Random\Engine\Secure
6af7e6f032055
6af7e6f032076
2つの uniqid() は末尾3桁しか違いません。uniqid() は現在時刻(マイクロ秒)を16進で書いたものだからです。重複しにくいだけで、秘密ではありません。PHP 8.5 は古い書き方に警告も出します。MT_RAND_PHP は Deprecated: Constant MT_RAND_PHP is deprecated since 8.3, as it uses a biased non-standard variant of Mt19937、lcg_value() は Deprecated: Function lcg_value() is deprecated since 8.4, use \Random\Randomizer::getFloat() instead と表示されました。
| 言語 | 安全な乱数源(注記がなければ実行済み) | 秘密に使わない |
|---|---|---|
| Python | secrets.token_bytes/hex/urlsafe、os.urandom |
random.* |
| PHP | random_bytes、random_int、Random\Randomizer() |
mt_rand、rand、uniqid、lcg_value |
| Java 26 | java.security.SecureRandom(アルゴリズム表示は NativePRNG) |
java.util.Random、Math.random |
| Dart | Random.secure() |
Random() |
| CLI | openssl rand -hex 32 |
シェルの $RANDOM |
| ブラウザ JS | crypto.getRandomValues、crypto.randomUUID(未実行:手元の jsc には crypto がなく、typeof crypto は undefined) |
Math.random |
トークンの長さと推測にかかる時間
足りる長さは「ビット数 ÷ 試行速度」で決まります。特定の1個を当てるには、平均して全体の半分を試す必要があります。速度は実測ではなく仮定です。レート制限のないオンライン窓口なら毎秒1,000回、オフラインで照合できる場合(たとえばトークンをソルトなしの高速ハッシュで保存していて漏れた場合)なら毎秒10⁹回。最後の列は有効なトークンが同時に100万個あり、どれか1個当たればよい場合です。
| トークン | ビット | 10³回/秒 オンライン | 10⁹回/秒 オフライン | 有効 10⁶個・10⁹回/秒 |
|---|---|---|---|---|
| 6桁の数字 | 19.9 | 8.3分 | 0.0005秒 | 一瞬 |
| 16進8文字 | 32 | 24.9日 | 2.15秒 | 一瞬 |
| 16進16文字 | 64 | 約10⁸年 | 292年 | 2.56時間 |
| UUIDv4 | 122 | 約10²⁶年 | 約10²⁰年 | 約10¹⁴年 |
token_hex(16) |
128 | 約10²⁸年 | 約10²²年 | 約10¹⁶年 |
token_urlsafe(32) |
256 | 約10⁶⁶年 | 約10⁶⁰年 | 約10⁵⁴年 |
教訓は2つ。6桁コードが安全なのは、短い有効期限と試行回数の上限(たとえば5回で無効)があるからで、コード自体の強さではありません。そして64ビットは大きく見えても、同時に有効なトークンが多いと一気に縮みます。最低128ビット、標準は256ビット。コストはほぼゼロです。
UUIDv4 のエントロピー: バージョン4の UUID は128ビットのうち4ビットがバージョン、2ビットがバリアントで、ランダムなのは122ビットです。CPython の uuid.uuid4() は中身が UUID(bytes=os.urandom(16), version=4) そのもの(inspect.getsource で確認)なので、Python では CSPRNG 由来です。ただしほかのライブラリやほかのバージョンは違います。v1 は時刻とノード ID を含み、v7 はミリ秒の時刻で始まります。UUID は識別子の形式です。「秘密」が欲しいときは secrets を使いましょう。
時刻でシードする危険を計測する
もう1つの定番の間違いは、リクエストごとに現在時刻でシードし直すことです。古い C の srand(time(NULL)) の写経からよく生まれます。
# timeseed.py - 時刻シードが危険な理由(手元だけのデモ)
import random, time
def reset_token(now): # 悪い例:毎回時刻でシードし直す
r = random.Random(int(now))
return f"{r.getrandbits(128):032x}"
T = 1794614400 # 固定の「サーバー時刻」(2026-11-14 00:00 UTC)
a = reset_token(T + 0.12) # 利用者Aが再設定を申し込む
b = reset_token(T + 0.87) # 利用者B、同じ1秒の中
print("A:", a); print("B:", b); print("same token:", a == b)
victim = reset_token(T + 37.4) # 観察者は時刻を前後1分で知っている
cands = [reset_token(s) for s in range(T - 60, T + 61)]
print("candidates:", len(cands), "victim in list:", victim in cands)
A: 7a7c99a76d9b36196739a5195bf659ee
B: 7a7c99a76d9b36196739a5195bf659ee
same token: True
candidates: 121 victim in list: True
同じ1秒に申し込んだ2人に、同じ再設定トークンが発行されました。しかも申し込みのおおよその時刻を知っている人(自分で申し込ませることもできます)が試す候補は121個だけ。見た目は128ビットでも、実際の不確かさは7ビットほどしかありません。
候補はどれくらいの速さで作れるか。1日分86,400秒のシードそれぞれでトークンを作ると 1.15秒(5回の中央値、1.146〜1.158秒)、1コアで毎秒約75,000シードでした。この実測速度から計算すると、1年分の秒単位シードは約7分、1時間分のミリ秒単位シード(360万個)は約48秒。時計を細かくしても助かりません。
ルール: セキュリティ目的で生成器を自分でシードしない。CSPRNG にこちらからシードを渡す必要はありません。
速度:安全な乱数は遅い?
よく言われる言い訳が速度です。128ビットのトークン1個を作る処理を20万回×5セット計測し、中央値を取りました。
secrets.token_hex(16) は 2,109 ns、random.getrandbits(128) は 254 ns。約8倍遅いものの、それでも2マイクロ秒です。100万個作っても約2秒。PHP 8.5 では bin2hex(random_bytes(16)) が 310 ns、mt_rand() が 22 ns(20万回×5セットの中央値)でした。どちらも、トークンを保存する DB 書き込みのコストとは比べものになりません。
正直な限界とバージョンの注意
- 実行したのは Python 3.9.6 だけです。
secretsは Python 3.6(PEP 506)、random.randbytesは 3.9 で追加。それより古い版では試していません。 - 状態の形式
(3, 625要素のタプル, None)は CPython の実装詳細です。3.9.6 では動きました。 - PHP の
random_bytes/random_intは 7.0 から、Random\Randomizerは 8.2 から。上の非推奨メッセージは 8.5.6 のものです。 - 推測時間の表は仮定の速度(10³回/秒・10⁹回/秒)による計算で、実測ではありません。現実のオンライン攻撃の速度は、あなたのレート制限で決まります。
- ブラウザの
crypto.getRandomValues、Node のcrypto.randomBytesは実行していません。 - 切り詰められた出力(
randrange(10**6)など)から状態を復元する方法は研究がありますが、ここでは実演していません。
練習問題
secrets.choiceで[a-z0-9]から10文字を一様に選んだトークンのエントロピーは何ビットか。セッション ID として足りるか。- アプリがパスワード再設定トークンを DB に平文で保存している。テーブルが漏れたときのリスクを、シリーズのどの考え方で防げるか。代わりに何を保存するか。
解答
- log2(36¹⁰) = 10 × log2(36) ≈ 51.7ビット。セッション ID には短すぎます。毎秒10⁹回のオフライン試行なら1個あたり平均約21日、有効なトークンが多ければもっと短くなります。
secrets.token_urlsafe(32)を使いましょう。 - トークンのハッシュを保存します。このトークン自体が256ビットのランダム値なので、人間が作るパスワード(第2回)と違い SHA-256 で十分です。リンクが使われたら受け取ったトークンをハッシュして照合し、1回使ったら、または期限が切れたら削除します。
次へ
- 前回:セキュリティ基礎講座 2/6:bcrypt と Argon2 でパスワードをハッシュ化
- 次回:セキュリティ基礎講座 4/6:JWT HS256 署名の落とし穴(弱い秘密鍵が100語のリストで破られます)
- トークンの通り道:HTTP の Cookie とセッション、PHP の XSS・CSRF・セッション対策
- トークンを安全に比較する方法は第6回:タイミング攻撃と定数時間比較
よくある質問
Python の random でセッション ID やパスワード再設定トークンを作ってもいい?
だめです。random はメルセンヌ・ツイスタ(MT19937)で、手元の実験では連続した32ビット出力624個から内部状態を約1ミリ秒で復元でき、次のパスワード再設定 URL のトークンを3つとも的中させました。secrets.token_urlsafe(32) などを使ってください。
トークンは何文字(何ビット)あれば足りる?
CSPRNG から最低128ビット、標準は256ビット(secrets.token_urlsafe(32))にしてください。記事の表では、32ビットのトークンは毎秒10億回のオフライン試行で約2秒、128ビットなら同じ速度でも約10^22年かかります。
UUIDv4 を秘密のトークン代わりに使ってもいい?
Python の uuid.uuid4() は os.urandom(16) を読むのでランダム部分は122ビットあり、推測はされません。ただし v1 や v7 は時刻を含み、同じ長さなら secrets のほうがエントロピーが多いので、秘密には secrets を使う習慣のほうが安全です。