この記事の結論Pythonの random はシミュレーション用途の決定論的な疑似乱数生成器であり、認証コードやセキュリティトークンには使えません。OTPの生成にはOSが提供する暗号学的乱数へアクセスする secrets を使い、短い有効期限、試行回数制限、一度だけの利用を必ず組み合わせます。
検証範囲以下の状態復元コードは、自分のPC内で生成したテストデータだけを対象にする学習用シミュレーションです。他者のアカウントや許可のないシステムに対して認証回避を試みてはいけません。

対象にしているOTPを整理する

ここで問題にするのは、サーバーがログインのたびにランダムな数字を発行し、メールやSMSなどで送るタイプのOTPです。実装例として random.randint(0, 999999) を使ってしまうケースを想定します。

認証アプリで使われるTOTPやHOTPは別の仕組みです。HOTPは共有秘密とカウンターからHMACを計算し、TOTPはカウンターの代わりに時刻を使います。これらを正しく実装した場合、PythonのMT19937からコードを生成するわけではありません。

なぜrandomは認証コードに使えないのか

Pythonの random は、内部でMersenne Twister(MT19937)を利用しています。周期が非常に長く統計用途には優秀ですが、同じ内部状態からは常に同じ出力列が生まれます。Python公式ドキュメントも、完全に決定論的で暗号用途には不適切だと明記しています。

MT19937は624個の32bitワードを中心とした状態を保持し、出力前にtemperと呼ばれるビット変換を行います。この変換は暗号学的な一方向関数ではなく逆算できます。そのため、同じ生成器から出た連続624個の完全な32bit出力を取得できると、状態ワードを復元し、その後の出力を予測できます。

623次元均等分布と状態復元は別の話MT19937が623次元均等分布することは統計的な性質です。「624出力で状態を復元できる」直接の理由は、有限の内部状態を持つ決定論的生成器であり、32bit出力に施すtemper変換を逆算できることです。

攻撃が成立する条件

sequenceDiagram
  autonumber
  participant Service as 脆弱なサービス
  participant RNG as 共有MT19937
  participant Observer as 出力を観測できる人物
  participant Victim as 別ユーザー

  Service->>RNG: 乱数を要求
  RNG-->>Service: 連続した完全な32bit出力
  Service-->>Observer: 624個の出力が漏れる
  Observer->>Observer: untemperして内部状態を複製
  Victim->>Service: OTPを要求
  Service->>RNG: 次の乱数を要求
  Observer->>Observer: 次の出力を予測

重要なのは、観測する値の形式です。提示した復元方法が直接使えるのは、getrandbits(32) のような完全な32bit出力が、同じインスタンスから連続して得られる場合です。

「6桁OTPを624回見れば突破できる」とは限らない6桁OTPは通常、元の乱数を100万通りへ縮めた値です。上位ビットが失われ、randrange の棄却処理で消費される出力数も一定とは限りません。そのため、624個の6桁コードをそのままuntemperへ渡しても状態は復元できません。ただし、だから random が安全になるわけではありません。別機能から完全な出力や十分な情報が漏れたり、弱いseedを推測されたりする設計では予測可能性が残ります。

ローカルで状態復元をシミュレーションする

次のコードは、未知のseedを持つテスト用生成器から完全な32bit出力を624個受け取り、同じ状態を持つ生成器をローカルに複製します。ネットワーク通信や実際のOTPサービスには接続しません。

from __future__ import annotations

import random
from typing import ClassVar


class MT19937CloneDemo:
    MASK_32: ClassVar[int] = 0xFFFFFFFF
    N_WORDS: ClassVar[int] = 624

    @classmethod
    def _invert_right_xor(cls, y: int, shift: int) -> int:
        """x ^ (x >> shift) = y を解く"""
        x = y
        for _ in range(32 // shift + 1):
            x = y ^ (x >> shift)
        return x & cls.MASK_32

    @classmethod
    def _invert_left_xor_mask(
        cls, y: int, shift: int, mask: int
    ) -> int:
        """x ^ ((x << shift) & mask) = y を解く"""
        x = y
        for _ in range(32 // shift + 1):
            x = y ^ ((x << shift) & mask)
        return x & cls.MASK_32

    @classmethod
    def untemper(cls, y: int) -> int:
        y = cls._invert_right_xor(y, 18)
        y = cls._invert_left_xor_mask(y, 15, 0xEFC60000)
        y = cls._invert_left_xor_mask(y, 7, 0x9D2C5680)
        return cls._invert_right_xor(y, 11)

    @classmethod
    def clone_from_outputs(cls, outputs: list[int]) -> random.Random:
        if len(outputs) < cls.N_WORDS:
            raise ValueError("624個の完全な32bit出力が必要です")

        words = tuple(cls.untemper(value) for value in outputs[:624])
        clone = random.Random()
        clone.setstate((3, words + (624,), None))
        return clone


victim_rng = random.Random(123456789)  # seedは攻撃者に未知と仮定
observed = [victim_rng.getrandbits(32) for _ in range(624)]
attacker_rng = MT19937CloneDemo.clone_from_outputs(observed)

predicted = [attacker_rng.getrandbits(32) for _ in range(10)]
actual = [victim_rng.getrandbits(32) for _ in range(10)]

print(predicted == actual)  # True

seedを総当たりしたわけではなく、出力から現在の状態を複製しています。過去の値まで自動的に分かるわけではありませんが、複製時点から先の出力列は一致します。これが、統計用PRNGを認証に使ってはいけない理由です。

secretsで安全なOTPを生成する

Pythonでは、パスワード、認証コード、リセットトークンなどの生成に secrets を使います。secrets はOSが提供する暗号学的に強い乱数源を利用します。

import secrets


def generate_numeric_otp(length: int = 6) -> str:
    if length not in range(6, 11):
        raise ValueError("OTPは6〜10桁で指定してください")

    value = secrets.randbelow(10 ** length)
    return f"{value:0{length}d}"


otp = generate_numeric_otp()
print(otp)  # 先頭が0でも常に6桁

secrets.randbelow() は指定範囲から偏りなく値を選びます。「起動時に一度seedを決めたMT19937を長期間使う」という構造ではなく、OSの安全な乱数機構へアクセスする点が重要です。OSごとの内部実装や再seedの詳細に依存した説明より、「Pythonがセキュリティ用途として提供するAPIを使う」と覚えるのが確実です。

乱数を替えるだけでは不十分

6桁コードには100万通りしかありません。生成方法が安全でも、試行回数を制限しなければオンライン総当たりを受けます。OTP認証フロー全体に次の対策が必要です。

対策実装上の要点
短い有効期限数分程度で失効させ、サーバー側の時刻で判定する
試行回数制限ユーザー、セッション、IPなど複数の単位で失敗回数を制限する
一度だけ利用検証成功と消費を同じトランザクションで処理し、再利用を防ぐ
用途との紐付けユーザーID、ログイン試行、メール変更などの目的に結び付ける
安全な保存平文保存を避ける。6桁は単純なハッシュだけでは総当たりしやすいため、サーバー側秘密鍵を使ったHMACなどを検討する
ログの秘匿OTP本体、seed、共有秘密をアプリケーションログへ出力しない

TOTPの場合も、有効時間内に一度成功したコードを再度受け付けないようにします。許容する時刻のずれを広くするほど攻撃可能な時間も広がるため、必要最小限にします。

なぜアルゴリズムが公開されていても安全なのか

安全性をアルゴリズムの秘密に依存させない考え方は、ケルクホフスの原理として知られています。仕組みを公開して専門家の検証にさらし、攻撃者がアルゴリズムを知っていても、秘密鍵や予測不能な内部状態を持たない限り破れない設計を目指します。

MT19937の問題はアルゴリズムが公開されていることではなく、そもそも暗号学的な予測困難性を目的に設計されていないことです。一方、CSPRNGはアルゴリズムが知られていても、十分なエントロピーを持つ秘密状態が分からなければ次の出力を現実的に予測できないよう設計されます。

まとめ

random は高速で再現可能なシミュレーションに適していますが、OTP生成には不適切です。連続した完全なMT19937出力が漏れる条件では、内部状態を複製して将来の値を予測できます。一方で、典型的な6桁OTPを624個見ただけで同じ復元ができるわけではありません。この条件の違いを理解したうえで、認証用途には secrets を使い、期限、試行制限、一度限りの消費まで含めて守る必要があります。

詳細は Python random公式ドキュメント、Python secrets公式ドキュメント、標準的なOTPについては RFC 4226(HOTP) と RFC 6238(TOTP) を参照してください。