なぜMath.random()でパスワードを生成してはいけないのか:Web Crypto APIで学ぶセキュアな乱数生成

はじめに
パスワードジェネレーターを実装する際、多くの初心者がMath.random()を使ってしまいます。一見動作するように見えますが、セキュリティ上の重大な欠陥があります。
この記事では、Next.js + TypeScriptでセキュアなパスワードジェネレーターを実装しながら、暗号学的に安全な乱数生成の重要性を学んでいきます。
Math.random()の問題点
簡単なパスワード生成を考えてみましょう。
// ❌ セキュリティ用途に不適
const generateWeakPassword = (length: number): string => {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let password = '';
for (let i = 0; i < length; i++) {
password += charset[Math.floor(Math.random() * charset.length)];
}
return password;
};
一見問題なさそうですが、Math.random()には以下の問題があります。
- 擬似乱数生成器(PRNG)のため、予測可能
- シード値が分かれば出力を再現できる
- 暗号学的用途には不適切
実際、V8エンジン(Chrome/Node.js)のMath.random()は、xorshift128+というアルゴリズムを使用しており、連続した出力から内部状態を推測可能です。
正しいアプローチ:Web Crypto API
Web Crypto APIのcrypto.getRandomValues()は、OSの乱数生成器(/dev/urandomなど)から値を取得します。
const generateSecurePassword = (length: number): string => {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// 暗号学的に安全な乱数を生成
const array = new Uint32Array(length);
crypto.getRandomValues(array);
let password = '';
for (let i = 0; i < length; i++) {
password += charset[array[i] % charset.length];
}
return password;
};
なぜUint32Arrayを使うのか
crypto.getRandomValues()は、型付き配列(Typed Array)にランダムな値を書き込みます。
const array = new Uint32Array(16); // 32ビット符号なし整数 × 16個
crypto.getRandomValues(array);
console.log(array);
// Uint32Array(16) [3847293847, 1283746829, ...]
Uint32Arrayを選ぶ理由は、文字セットのインデックスとして使うには十分な範囲(0~4,294,967,295)があるためです。
モジュロバイアスの問題
実装をよく見ると、array[i] % charset.lengthという処理があります。これには「モジュロバイアス」という問題があります。
例: charset.length = 62(大小英数)
Uint32Arrayの範囲: 0 ~ 4,294,967,295
4,294,967,295 % 62 = 59
→ 0~59は約69,273,988回出現
→ 60~61は約69,273,987回出現(1回少ない)
厳密にはバイアスがありますが、差は0.0000015%程度で実用上無視できます。
完全にバイアスを除去するには、以下のようなリジェクションサンプリングを使います。
const getRandomIndex = (max: number): number => {
const range = Math.floor(0xFFFFFFFF / max) * max;
let value;
do {
const arr = new Uint32Array(1);
crypto.getRandomValues(arr);
value = arr[0];
} while (value >= range);
return value % max;
};
ただし、パスワード生成程度であれば最初の実装で十分です。
正規表現パターンからのパスワード生成
組織によっては「大文字・小文字・数字を必ず1文字以上含む」といったポリシーがあります。これを正規表現で表現できると便利です。
^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])[A-Za-z0-9]{16}$
この正規表現からパスワードを生成する実装を見ていきます。
正規表現パターンの解析
文字クラス[A-Za-z0-9]を解析して、実際の文字セットに展開します。
const expandRegexPattern = (pattern: string): string => {
let result = '';
let i = 0;
while (i < pattern.length) {
if (pattern[i] === '[') {
const closeBracket = pattern.indexOf(']', i);
if (closeBracket === -1) break;
const content = pattern.substring(i + 1, closeBracket);
let chars = '';
let j = 0;
while (j < content.length) {
if (j + 2 < content.length && content[j + 1] === '-') {
// 範囲指定(例: A-Z)
const start = content[j].charCodeAt(0);
const end = content[j + 2].charCodeAt(0);
for (let code = start; code <= end; code++) {
chars += String.fromCharCode(code);
}
j += 3;
} else {
// 単一文字
chars += content[j];
j++;
}
}
// 繰り返し回数の解析
let repeatCount = 1;
if (pattern[closeBracket + 1] === '{') {
const closeCurly = pattern.indexOf('}', closeBracket);
if (closeCurly > -1) {
repeatCount = parseInt(pattern.substring(closeBracket + 2, closeCurly));
i = closeCurly + 1;
}
} else {
i = closeBracket + 1;
}
// セキュアな乱数で文字を選択
const randomValues = new Uint32Array(repeatCount);
crypto.getRandomValues(randomValues);
for (let j = 0; j < repeatCount; j++) {
result += chars[randomValues[j] % chars.length];
}
} else {
result += pattern[i];
i++;
}
}
return result;
};
解析の流れを追ってみましょう。
入力: [A-Za-z0-9]{16}
ステップ1: '[' 検出
ステップ2: 'A-Z' 検出 → ASCII 65~90
→ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
ステップ3: 'a-z' 検出 → ASCII 97~122
→ "abcdefghijklmnopqrstuvwxyz"
ステップ4: '0-9' 検出 → ASCII 48~57
→ "0123456789"
ステップ5: chars = "ABC...XYZabc...xyz012...789" (62文字)
ステップ6: '{16}' 検出 → 16回繰り返し
ステップ7: crypto.getRandomValues()で16個の乱数生成
ステップ8: 各乱数をcharsのインデックスに変換
パスワード強度の可視化
生成したパスワードの強度を判定します。
const getPasswordStrength = (password: string) => {
if (!password) return { label: '', color: '', width: 0 };
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (password.length >= 16) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/[0-9]/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
if (strength <= 2) return { label: '弱い', color: 'bg-red-500', width: 25 };
if (strength <= 4) return { label: '普通', color: 'bg-yellow-500', width: 50 };
if (strength <= 6) return { label: '強い', color: 'bg-blue-500', width: 75 };
return { label: '非常に強い', color: 'bg-green-500', width: 100 };
};
この判定方法は簡易的ですが、直感的です。
より正確な評価には「エントロピー」を計算する方法があります。
エントロピーによる評価
エントロピーは「パスワードの推測困難性」を表す指標です。
エントロピー = log2(可能な組み合わせ数)
例1: 8文字・大小英字のみ(52種類)
エントロピー = 8 * log2(52) ≈ 45.6ビット
例2: 16文字・大小英数記号(94種類)
エントロピー = 16 * log2(94) ≈ 104.8ビット
NISTのガイドライン(SP 800-63B)では、80ビット以上が推奨されています。
実装例:
const calculateEntropy = (password: string): number => {
let charsetSize = 0;
if (/[a-z]/.test(password)) charsetSize += 26;
if (/[A-Z]/.test(password)) charsetSize += 26;
if (/[0-9]/.test(password)) charsetSize += 10;
if (/[^a-zA-Z0-9]/.test(password)) charsetSize += 32; // 概算
return password.length * Math.log2(charsetSize);
};
似た文字の除外機能
パスワードを手書きメモしたり、電話で伝える場合、以下の文字は誤読されやすいです。
i(アイ)、l(エル)、1(イチ)、L(大文字エル)
o(オー)、0(ゼロ)、O(大文字オー)
これらを除外する実装:
const similarChars = 'il1Lo0O';
if (excludeSimilar) {
charset = charset.split('')
.filter(char => !similarChars.includes(char))
.join('');
}
ただし、文字セットが減るとエントロピーも減少するため、長さを増やして補う必要があります。
クリップボードAPIのセキュリティ
const handleCopy = async (password: string) => {
try {
await navigator.clipboard.writeText(password);
} catch (err) {
// エラーハンドリング
}
};
Clipboard APIを使う際の注意点:
- HTTPSまたはlocalhostでのみ動作
- ユーザーの明示的な操作(クリックなど)が必要
- クリップボードの内容は他のアプリからも読み取り可能
セキュリティ向上のため、以下の対策が考えられます。
useEffect(() => {
// 30秒後にクリップボードをクリア
const timer = setTimeout(() => {
navigator.clipboard.writeText('');
}, 30000);
return () => clearTimeout(timer);
}, [copiedIndex]);
今後の拡張可能性
- パスワードマネージャー連携(WebAuthn)
- エントロピー表示
- パスワードポリシーのプリセット保存
- 辞書攻撃シミュレーション(推測時間の計算)
まとめ
セキュアなパスワード生成には、以下が重要です。
-
crypto.getRandomValues()の使用 - 十分な長さと文字種の多様性
- 正規表現パターンによるポリシー対応
- パスワード強度の可視化
Math.random()は便利ですが、セキュリティ用途には絶対に使わないようにしましょう。
ツールを実際に試す:TechTools - パスワード生成
Discussion