プログラミングにおける乱数:PRNG、CSPRNG、使い分けの判断基準

9 min2026年6月6日

コンピュータの「ランダム」とは

コンピュータは本質的に決定論的な機械です。同じ入力を与えれば常に同じ出力を返すことが、コンピュータの最も基本的な性質です。そのため「真のランダム」をソフトウェアだけで実現することは原理的に不可能です。私たちが日常的に使う「乱数」の大半は、実際には決定論的なアルゴリズムによって生成された擬似乱数(Pseudorandom Number)です。

真の乱数(True Random Number)を得るには、物理現象を利用する必要があります。放射性崩壊、大気ノイズ、熱雑音などの量子力学的・物理的プロセスは予測不能であり、これらをデジタル化することで真の乱数が得られます。random.orgは大気ノイズを利用し、IntelのRDRAND命令はチップ内蔵のハードウェア乱数生成器を使っています。

しかし、ほとんどのプログラミング用途では真の乱数は不要です。重要なのは「目的に対して十分な予測不能性があるか」です。ゲームのサイコロシミュレーションには統計的な均一性があれば十分ですが、暗号鍵の生成には計算論的に予測不能な乱数が必要です。この「十分さ」の基準が、PRNGとCSPRNGを使い分ける理由です。

OSはハードウェアからのエントロピー(キー入力のタイミング、マウスの動き、ディスクI/Oのタイミングなど)をエントロピープールに蓄積し、/dev/urandom(Linux)やBCryptGenRandom(Windows)といったインターフェースでアプリケーションに提供しています。これがCSPRNGの基盤となります。

PRNG(Math.random)の仕組み

PRNG(Pseudorandom Number Generator:擬似乱数生成器)は、初期値(シード)から決定論的なアルゴリズムで数列を生成します。シードが同じなら常に同じ数列が得られます。これは再現性が必要な場面(テスト、シミュレーション)では利点ですが、セキュリティの文脈では致命的な弱点です。

JavaScriptのMath.random()は内部的にxorshift128+アルゴリズムを使用しています(V8エンジンの場合)。128ビットの内部状態を持ち、ビットシフトとXOR演算を組み合わせて次の値を算出します。生成速度は非常に高速で、毎秒数億個の乱数を生成できます。しかし、内部状態が128ビットしかないため、十分な出力を観察すれば状態を逆算し、将来の出力を予測できます。

Math.random()の統計的品質は高く、TestU01のSmallCrushやBigCrushといった統計テストスイートで良好な結果を示します。一様分布に従い、系列相関も低いため、統計的なシミュレーションには十分使えます。ただし、暗号学的安全性は一切保証されていません。ECMAScript仕様にも「暗号学的に安全な乱数を提供しない」と明記されています。

他の代表的なPRNGアルゴリズムとしては、Mersenne Twister(MT19937)があります。623次元で均等分布し、周期は2^19937-1と非常に長いですが、624個の出力を観察すれば内部状態を完全に復元できます。Pythonのrandomモジュール、RubyのRandom、PHPのmt_rand()がこれを使用しています。

// PRNGの基本的な動作を理解するための簡易実装(xorshift32)
function xorshift32(state) {
  // 内部状態をビット演算で更新する
  state ^= state << 13;
  state ^= state >>> 17;
  state ^= state << 5;
  return state >>> 0; // 符号なし32ビット整数に変換
}

// シードが同じなら常に同じ列が得られる(再現可能)
let seed = 12345;
seed = xorshift32(seed); // 常に同じ値: 406785034
seed = xorshift32(seed); // 常に同じ値: 3023898262

// Math.random() の使い方(非セキュリティ用途)
const diceRoll = Math.floor(Math.random() * 6) + 1; // 1〜6のサイコロ
const shuffled = array.sort(() => Math.random() - 0.5); // 配列シャッフル(偏りあり)

// ⚠️ Fisher-Yatesアルゴリズムで正しくシャッフル
function shuffle(arr) {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

CSPRNG:暗号論的に安全な乱数

CSPRNG(Cryptographically Secure Pseudorandom Number Generator:暗号論的擬似乱数生成器)は、通常のPRNGに加えて「予測不能性」を保証します。具体的には、過去の出力をすべて観察しても、次の出力を多項式時間で予測することが計算論的に不可能であるという性質を持ちます。これはnext-bit testと呼ばれる基準です。

CSPRNGの実装はOSのエントロピーソースに依存します。Linuxの/dev/urandomはChaCha20ベースのCSPRNGで、カーネルがハードウェアから収集したエントロピーを種として使います。WindowsのBCryptGenRandom、macOSのSecRandomCopyBytesも同様の原理で動作します。これらはブロック暗号やストリーム暗号を基盤としており、出力から内部状態を逆算することは暗号解読と同等の難しさです。

Web APIではcrypto.getRandomValues()がCSPRNGを提供します。Node.jsではcrypto.randomBytes()やcrypto.randomInt()が利用可能です。これらはOSのCSPRNGを直接呼び出しており、Math.random()とは根本的に異なるセキュリティ保証を提供します。パフォーマンスはPRNGより劣りますが、最新のハードウェアでは毎秒数百MBの生成が可能で、通常のアプリケーションではボトルネックになりません。

CSPRNGの速度が問題になる稀なケース(大規模モンテカルロシミュレーションなど)では、CSPRNGでPRNGのシードを生成し、PRNGで大量の乱数を生成するハイブリッドアプローチが使えます。ただし、このアプローチを取る場合、PRNG部分の出力にはセキュリティ保証がないことを明確に理解しておく必要があります。

// ブラウザ環境:crypto.getRandomValues()
// 暗号論的に安全な乱数を生成する
const array = new Uint32Array(10);
crypto.getRandomValues(array);
// array: [3847291045, 1928374650, ...] 予測不能な値

// 安全な範囲付き整数の生成(0 〜 max-1)
function secureRandomInt(max) {
  const array = new Uint32Array(1);
  crypto.getRandomValues(array);
  return array[0] % max; // 注意:maxが2のべき乗でないと微小な偏りあり
}

// 偏りのない範囲付き整数(rejection sampling)
function unbiasedRandomInt(min, max) {
  const range = max - min;
  const bytesNeeded = Math.ceil(Math.log2(range) / 8) || 1;
  const maxValid = Math.floor(256 ** bytesNeeded / range) * range;
  let value;
  do {
    const buf = new Uint8Array(bytesNeeded);
    crypto.getRandomValues(buf);
    value = buf.reduce((acc, byte, i) => acc + byte * (256 ** i), 0);
  } while (value >= maxValid); // 偏りが出る範囲は捨てる
  return min + (value % range);
}

// Node.js環境:crypto.randomInt()(Node 14.10+)
import { randomInt, randomBytes } from 'node:crypto';
const secureInt = randomInt(1, 100); // 1〜99の安全な整数
const token = randomBytes(32).toString('hex'); // 256ビットのトークン

PRNGで十分な場面

ゲーム開発における多くの乱数使用はPRNGで十分です。敵の出現位置、ドロップアイテムの決定、パーティクルエフェクトの挙動、地形のプロシージャル生成などは、統計的な均一性さえあれば目的を達成できます。むしろ、シードを固定して再現可能にしたい場面(リプレイ機能、デバッグ)ではPRNGが必須です。

科学技術計算やモンテカルロシミュレーションでは、大量の乱数を高速に生成する必要があります。PRNGはCSPRNGの100〜1000倍高速であり、数十億個の乱数を生成するシミュレーションではこの差が致命的です。ただし、シミュレーション結果の品質はPRNGの統計的性質に依存するため、Mersenne TwisterやPCGのような高品質なPRNGを使用してください。

UIのアニメーション、A/Bテストのユーザー振り分け(セキュリティ要件がない場合)、ランダムなプレースホルダーデータの生成、テストデータのファクトリーなども、PRNGで問題ありません。要は「攻撃者が予測しても被害がない」場面ではPRNGを使って構いません。

データベースのサンプリングやシャッフル表示(「おすすめ記事」のランダム表示など)もPRNGで十分です。ただし、くじ引き、抽選、賭博など結果に金銭的価値がある場合は、たとえ小額であってもCSPRNGを使うべきです。予測可能な乱数による抽選は法的問題に発展する可能性があります。

CSPRNGが必要な場面

暗号鍵の生成は最もわかりやすい例です。AES-256の鍵、RSAの素数生成、楕円曲線暗号のスカラー値、TLSセッションのプリマスターシークレットなど、暗号プロトコルに関わるすべての乱数はCSPRNGから生成しなければなりません。PRNGを使った暗号鍵は、鍵長に関係なく脆弱です。

セッションID、CSRF トークン、パスワードリセットトークン、APIキーなど、秘密として扱われるすべてのトークンにはCSPRNGが必要です。攻撃者がトークンを予測できれば、認証をバイパスし、他人のセッションを乗っ取れます。実際に、Math.random()で生成されたセッションIDが攻撃者に予測され、大規模なアカウント乗っ取りに至った事例は複数報告されています。

UUIDv4の生成もCSPRNGを使うべきです。UUIDv4は122ビットのランダム値ですが、外部に公開される識別子として使われる場合、予測可能であればリソース列挙攻撃(IDOR)に悪用されます。uuid-generatorツールが内部でcrypto.getRandomValues()を使用しているのはこの理由です。

パスワード生成、ソルト生成、ノンス(Number Used Once)、初期化ベクトル(IV)もすべてCSPRNGの出番です。password-generatorツールでは、文字列プールからの選択にcrypto.getRandomValues()を使い、生成されたパスワードが計算論的に予測不能であることを保証しています。一般的な原則として「攻撃者が値を予測できたら被害が発生する」場面ではCSPRNGを使ってください。

よくある間違いとアンチパターン

最も多い間違いは、セキュリティが必要な場面でMath.random()を使用することです。OTPコードの生成、一時パスワード、招待リンクのトークンなど、「ちょっとしたランダム値」が必要な場面で安易にMath.random()に頼るケースが頻発しています。コードレビューで「crypto使ってください」と指摘されるまで気づかないことも多いです。

Date.now()やprocess.pidをシードに使う「手動シード」も危険なパターンです。タイムスタンプは攻撃者が容易に推測でき(ミリ秒精度でも試行範囲は狭い)、PIDは数千通りしかありません。シードの予測可能性は出力の予測可能性に直結します。シード用のエントロピーが必要なら、必ずOSのCSPRNGから取得してください。

モジュロバイアス(modulo bias)も見落とされがちな問題です。例えば、0〜255の一様な乱数から0〜99の範囲を得るためにrandByte % 100とすると、0〜55が56〜99より多く出現します(256 = 2*100 + 56なので)。小さな偏りですが、大量に生成すると統計的に有意になります。rejection samplingで解消できます。

Math.random()による配列シャッフルの実装ミスも頻出します。array.sort(() => Math.random() - 0.5)は一見動きますが、ソートアルゴリズムの比較関数としての要件(推移律)を満たさないため、特定の要素が特定の位置に偏ります。正しいシャッフルにはFisher-Yatesアルゴリズムを使い、セキュリティが必要ならCSPRNGと組み合わせてください。

// ❌ 間違い1:セキュリティ用途でMath.random()
function generateOTP() {
  // 6桁のOTPコードを生成(危険!予測可能)
  return Math.floor(Math.random() * 1000000).toString().padStart(6, '0');
}

// ✅ 正しい実装:CSPRNGを使う
function generateSecureOTP() {
  // crypto.randomInt()で安全に生成
  const { randomInt } = require('node:crypto');
  return randomInt(0, 1000000).toString().padStart(6, '0');
}

// ❌ 間違い2:予測可能なシード
function badPRNG() {
  const seed = Date.now(); // 攻撃者はリクエスト時刻を推測できる
  return mulberry32(seed); // 出力も予測可能に
}

// ❌ 間違い3:モジュロバイアス
function biasedDice(randomByte) {
  return (randomByte % 6) + 1;
  // 256 / 6 = 42余り4 → 1〜4が5〜6より出やすい
}

// ✅ 正しい実装:rejection sampling
function unbiasedDice() {
  const limit = 252; // 6 * 42 = 252(6で割り切れる最大値)
  let byte;
  do {
    byte = crypto.getRandomValues(new Uint8Array(1))[0];
  } while (byte >= limit); // 252〜255は棄却
  return (byte % 6) + 1;
}

// ❌ 間違い4:sort()でシャッフル(偏りが出る)
const badShuffle = arr => [...arr].sort(() => Math.random() - 0.5);

// ✅ 正しい実装:Fisher-Yates + CSPRNG
function secureShuffle(arr) {
  const result = [...arr];
  for (let i = result.length - 1; i > 0; i--) {
    const j = crypto.randomInt(0, i + 1); // Node.js 14.10+
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

一様分布以外の乱数生成

これまで扱ってきた乱数はすべて一様分布(uniform distribution)に従うものでした。しかし、現実のシミュレーションでは正規分布、指数分布、ポアソン分布など、さまざまな確率分布に従う乱数が必要になります。これらは一様分布の乱数を変換することで生成できます。

正規分布(ガウス分布)の乱数生成にはBox-Muller変換が広く使われています。2つの独立な一様乱数U1, U2から、Z = √(-2 ln U1) × cos(2π U2)で標準正規分布に従う値が得られます。平均μ、標準偏差σの正規分布にはX = μ + σ × Zで変換できます。成績分布、測定誤差、自然現象のモデリングに使われます。

指数分布はイベントの待ち時間(次の顧客が来るまでの時間、次のリクエストが来るまでの間隔など)のモデリングに使います。逆変換法で、一様乱数UからX = -ln(U) / λ(λはレート)で生成できます。ポアソン分布は一定時間内のイベント発生回数のモデリングに使い、指数分布の待ち時間を累積する方法で生成できます。

ゲーム開発では重み付き抽選(ガチャ確率)がよく使われます。各アイテムに確率を割り当て、累積分布関数(CDF)を構築してbinary searchで選択する方法が効率的です。また、正規分布に似た「ゆるい山型」が欲しいだけなら、一様乱数を複数回足すことで中心極限定理を近似的に利用する方法もあります(例:3d6のような複数ダイスの合計)。

// Box-Muller変換:一様分布 → 正規分布
function normalRandom(mean = 0, stddev = 1) {
  const u1 = Math.random(); // (0, 1)の一様乱数
  const u2 = Math.random();
  // 標準正規分布に変換
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return mean + stddev * z; // 任意の平均・標準偏差に調整
}

// 指数分布:イベント間隔のシミュレーション
function exponentialRandom(rate) {
  // rate = 1/平均待ち時間(例:1時間に5回来る → rate = 5)
  return -Math.log(Math.random()) / rate;
}

// 重み付きランダム選択(ガチャ・ドロップテーブル)
function weightedRandom(items) {
  // items = [{value: 'SSR', weight: 3}, {value: 'SR', weight: 15}, ...]
  const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
  let random = Math.random() * totalWeight;
  for (const item of items) {
    random -= item.weight;
    if (random <= 0) return item.value;
  }
  return items[items.length - 1].value; // フォールバック
}

// ポアソン分布:一定時間内のイベント発生回数
function poissonRandom(lambda) {
  // Knuthのアルゴリズム(lambda < 30で有効)
  const L = Math.exp(-lambda);
  let k = 0;
  let p = 1;
  do {
    k++;
    p *= Math.random();
  } while (p > L);
  return k - 1;
}

// 使用例:1時間に平均4件の注文が来るシミュレーション
const ordersThisHour = poissonRandom(4); // 0, 1, 2, 3, 4, 5... が確率的に出現