🔐

なぜ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()には以下の問題があります。

  1. 擬似乱数生成器(PRNG)のため、予測可能
  2. シード値が分かれば出力を再現できる
  3. 暗号学的用途には不適切

実際、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を使う際の注意点:

  1. HTTPSまたはlocalhostでのみ動作
  2. ユーザーの明示的な操作(クリックなど)が必要
  3. クリップボードの内容は他のアプリからも読み取り可能

セキュリティ向上のため、以下の対策が考えられます。

useEffect(() => {
  // 30秒後にクリップボードをクリア
  const timer = setTimeout(() => {
    navigator.clipboard.writeText('');
  }, 30000);

  return () => clearTimeout(timer);
}, [copiedIndex]);

今後の拡張可能性

  1. パスワードマネージャー連携(WebAuthn)
  2. エントロピー表示
  3. パスワードポリシーのプリセット保存
  4. 辞書攻撃シミュレーション(推測時間の計算)

まとめ

セキュアなパスワード生成には、以下が重要です。

  1. crypto.getRandomValues()の使用
  2. 十分な長さと文字種の多様性
  3. 正規表現パターンによるポリシー対応
  4. パスワード強度の可視化

Math.random()は便利ですが、セキュリティ用途には絶対に使わないようにしましょう。

ツールを実際に試す:TechTools - パスワード生成

Discussion