Reactで、パスワードを作るボタンを作る方法

​​React​Playwright,TypeScript,Web Crypto API

ボタンを押すたびに、ランダムなパスワードを作って表示する部品を React で作りました。長さと使う文字の種類を選べて、できたパスワードはコピーのボタンでクリップボードに入れられます。

作るときに気をつけるのは、乱数の取り方です。乱数は Math.random() ではなく crypto.getRandomValues() から取ります。そのうえで、乱数を文字の数で割った余りで文字を選ぶと、割り切れない場合は一部の文字が出やすくなります。その偏りを Node.js で数えて確かめ、偏らない選び方に直しました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げ、パスワード管理ツールとの組み合わせは試していません。

長さ16文字、小文字・大文字・数字・記号にチェックが入った部品。「パスワードを作る」を2回押すと、そのたびに下の欄のパスワードが別のものに変わる。続けて記号のチェックを外すと、組み合わせの数の表示が約2の99乗から約2の95乗に変わり、もう一度押すと記号を含まないパスワードになる。下の帯には「操作 n回目/4回」と出る
「パスワードを作る」を押すたびにパスワードが変わり、記号を外すと記号のないパスワードになる様子

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。押した瞬間だけ、変わった表示をピンクにしています。撮影のときは、フォントと文字の大きさを記事の CSS から変えています。

Math.random() ではなく crypto.getRandomValues() を使う

MDN の Math.random() の説明には、Math.random() の乱数は暗号に使えるほど安全ではなく、セキュリティに関わる目的には Crypto.getRandomValues() を使うように書かれています。パスワードはまさにその用途なので、Math.random() は使いません。

MDN の getRandomValues() の説明にあるとおり、crypto.getRandomValues() は、渡した Uint32Array などの配列を、暗号に使える強さの乱数で埋めて返します。同じページには、Crypto のうち保護されたコンテキストの外でも使えるのは getRandomValues() だけだ、とも書かれています。ブラウザだけでなく、Node.js 22 でも crypto.getRandomValues() をそのまま呼べました。後で Node.js で偏りを数えるときは、記事と同じコードを使っています。

割った余りで文字を選ぶと、偏りが出る

乱数から文字を1つ選ぶとき、乱数を文字の数で割った余りを使う書き方がすぐに思いつきます。ところが、乱数の取りうる値の数が、文字の数で割り切れないと偏りが出ます。

たとえば、0〜255 の1バイトの乱数を、小文字・大文字・数字の 62 文字で割ると、256 は 62 の4倍に 8 余ります。そのため、余りが 0〜7 になる値は5通り、8〜61 になる値は4通りで、並びの先頭8文字(a〜h)だけが出やすくなります。Node.js で、それぞれ 620万回選んで数えた結果です。

text
1バイト % 62 : 期待値 100000 回、最多 121956 回、最少 96265 回、最多/最少 = 1.267、多い順の10文字 = fdcaghbe82
  先頭8文字(a〜h)の平均 121369 回、残り54文字の平均 96834 回、比 1.253
randomInt(62): 期待値 100000 回、最多 100867 回、最少 99164 回、最多/最少 = 1.017、多い順の10文字 = QLsJoa4IAe

1バイトの余りで選ぶと、a〜h の8文字が、ほかの文字の約1.25倍出ました。計算どおり、5通りと4通りの比です。下の randomInt(62) で選んだほうは、どの文字もほぼ同じ回数でした。

偏りをなくすには、割り切れる範囲を超えた値を捨てて、引き直します。32ビットの乱数(0〜232−1)を使う関数は次のとおりです。

ts
// 0 以上 max 未満の整数を、偏りなく返す
export function randomInt(max: number): number {
  const range = 2 ** 32;
  // max で割り切れる範囲だけを使い、はみ出した値は捨てて引き直す
  const limit = range - (range % max);
  const buf = new Uint32Array(1);
  while (true) {
    crypto.getRandomValues(buf);
    if (buf[0] < limit) return buf[0] % max;
  }
}

limit は、232 以下で最大の、max の倍数です。limit 以上の値を捨てれば、残った値はどの余りにも同じ数ずつ当たります。捨てる値は、max が 62 なら4個、74 なら44個です。232(約43億)個のうちのこれだけなので、引き直しはほとんど起きません。

パスワードを作る関数

文字の種類と、乱数で文字を選んでパスワードを作る関数です。上の randomInt も同じファイルに入っています。

ts
export const CHARSETS = {
  lower: 'abcdefghijklmnopqrstuvwxyz',
  upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
  digit: '0123456789',
  symbol: '!#$%&*+-=?@_',
} as const;

export type CharsetKey = keyof typeof CHARSETS;

// 見分けにくい文字(大文字のアイ、小文字のエル、数字の1、大文字のオー、数字の0、小文字のオー)
const AMBIGUOUS = /[Il1O0o]/g;

export function buildPools(sets: CharsetKey[], excludeAmbiguous: boolean): string[] {
  return sets.map((k) => (excludeAmbiguous ? CHARSETS[k].replace(AMBIGUOUS, '') : CHARSETS[k]));
}

// 0 以上 max 未満の整数を、偏りなく返す
export function randomInt(max: number): number {
  const range = 2 ** 32;
  // max で割り切れる範囲だけを使い、はみ出した値は捨てて引き直す
  const limit = range - (range % max);
  const buf = new Uint32Array(1);
  while (true) {
    crypto.getRandomValues(buf);
    if (buf[0] < limit) return buf[0] % max;
  }
}

export function generatePassword(length: number, sets: CharsetKey[], excludeAmbiguous: boolean): string {
  const pools = buildPools(sets, excludeAmbiguous);
  if (pools.length === 0 || length < pools.length) {
    throw new RangeError('文字の種類が選ばれていないか、長さが足りません');
  }
  const all = pools.join('');
  // 選んだ種類の文字がすべて入るまで、丸ごと作り直す
  while (true) {
    let password = '';
    for (let i = 0; i < length; i++) password += all[randomInt(all.length)];
    if (pools.every((pool) => [...pool].some((c) => password.includes(c)))) return password;
  }
}

// 使う文字の数と長さから、組み合わせの数を2の何乗かで表す(目安)
export function estimateBits(length: number, sets: CharsetKey[], excludeAmbiguous: boolean): number {
  const size = buildPools(sets, excludeAmbiguous).join('').length;
  return size === 0 ? 0 : Math.floor(length * Math.log2(size));
}

使う文字を全部つなげた all から、長さの数だけ randomInt で選びます。「見分けにくい文字を使わない」を選んだときは、大文字の I(アイ)、小文字の l(エル)、数字の 1、大文字の O(オー)、数字の 0、小文字の o(オー)を除きます。手で書き写したり、読み上げたりするときに取り違えやすい文字です。

選んだ種類の文字が必ず入るようにするために、足りない種類があったら、そのパスワードは捨てて丸ごと作り直しています。丸ごと作り直せば、条件を満たすパスワードの中から等しく選ぶことになります。

作り直しが何回起きるかは、長さで大きく変わります。4種類(74文字)を使うときに、1回で4種類がそろう割合を、組み合わせの数から計算しました。長さ8では、generatePassword を10万回呼んで実際に数えた、1個あたりに作った回数も、計算と同じ約2.19回でした。

長さ 1回で4種類がそろう割合 平均で何回作るか
4 6.49% 15.40回
8 45.65% 2.19回
16 84.47% 1.18回
32 98.69% 1.01回

Node.js で1万回作る時間を測ると、長さ16で 659ms、作り直しの多い長さ4で 2339ms でした。長さ4でも1個あたり1ms未満なので、ボタンを1回押すたびに作る使い方なら、時間を気にする必要はありません。

ボタンと設定の部品

画面の部品は次のとおりです。長さはスライダー、使う文字はチェックボックスで選び、「パスワードを作る」を押したときだけ generatePassword を呼びます。

tsx
import { useState } from 'react';
import { CHARSETS, estimateBits, generatePassword, type CharsetKey } from './generatePassword';

const LABELS: Record<CharsetKey, string> = {
  lower: '小文字(a〜z)',
  upper: '大文字(A〜Z)',
  digit: '数字(0〜9)',
  symbol: '記号(!#$%&*+-=?@_)',
};
const KEYS = Object.keys(CHARSETS) as CharsetKey[];

export function PasswordGenerator() {
  const [length, setLength] = useState(16);
  const [sets, setSets] = useState<CharsetKey[]>(KEYS);
  const [excludeAmbiguous, setExcludeAmbiguous] = useState(false);
  const [password, setPassword] = useState('');
  const [copied, setCopied] = useState(false);

  // 長さの下限(4)は種類の数(最大4)以上なので、種類が1つ以上あれば作れる
  const canGenerate = sets.length > 0;

  const toggleSet = (key: CharsetKey) =>
    setSets((prev) => KEYS.filter((k) => (k === key ? !prev.includes(k) : prev.includes(k))));

  const handleGenerate = () => {
    setPassword(generatePassword(length, sets, excludeAmbiguous));
    setCopied(false);
  };

  const handleCopy = async () => {
    await navigator.clipboard.writeText(password);
    setCopied(true);
  };

  return (
    <div className="pg">
      <label className="pg-length">
        長さ:{length}文字
        <input
          type="range"
          min={4}
          max={64}
          value={length}
          onChange={(e) => setLength(Number(e.target.value))}
        />
      </label>
      <fieldset className="pg-sets">
        <legend>使う文字</legend>
        {KEYS.map((key) => (
          <label key={key}>
            <input type="checkbox" checked={sets.includes(key)} onChange={() => toggleSet(key)} />
            {LABELS[key]}
          </label>
        ))}
        <label>
          <input
            type="checkbox"
            checked={excludeAmbiguous}
            onChange={(e) => setExcludeAmbiguous(e.target.checked)}
          />
          見分けにくい文字(I l 1 O 0 o)を使わない
        </label>
      </fieldset>

      <button type="button" className="pg-generate" onClick={handleGenerate} disabled={!canGenerate}>
        パスワードを作る
      </button>
      {!canGenerate && <p className="pg-hint">使う文字を1種類以上選んでください。</p>}

      <div className="pg-result">
        <input
          aria-label="作ったパスワード"
          value={password}
          readOnly
          spellCheck={false}
          autoComplete="off"
        />
        <button type="button" onClick={handleCopy} disabled={password === ''}>
          {copied ? 'コピーしました' : 'コピー'}
        </button>
      </div>
      {canGenerate && (
        <p className="pg-bits">
          組み合わせの数:約 2<sup>{estimateBits(length, sets, excludeAmbiguous)}</sup> 通り
        </p>
      )}
    </div>
  );
}

パスワードは、ボタンのクリックで作って state に入れています。描画のたびに作る書き方にすると、長さを動かしただけでパスワードが変わってしまいます。検証環境では、長さを16文字から20文字に変えた直後もパスワードは前のままで、ボタンを押すと20文字になりました。

スライダーの最小値は4にしました。文字の種類は最大4つなので、長さが種類の数より短くなることはありません。MDN の input type="range" の説明にあるとおり、値は min より小さくなりません。検証環境でも、スライダーで Home キーを押すと4文字、End キーを押すと64文字で止まりました。種類をすべて外したときは、ボタンを押せなくして「使う文字を1種類以上選んでください。」と出します。

コピーのボタンは、MDN の writeText() の説明にある navigator.clipboard.writeText() を呼んでいます。検証環境では、クリップボードの中身が表示中のパスワードと一致し、ボタンの表示が「コピーしました」に変わりました。新しいパスワードを作ると「コピー」に戻ります。コピーに失敗したときの扱いなどは、以前の記事「Reactで、クリップボードにコピーするボタンを作る方法」で確かめています。

CSS は次のとおりです。

css
.pg { display: grid; gap: 12px; max-width: 480px; font-size: 15px; color: #1f2a30; }
.pg-length { display: grid; gap: 4px; }
.pg-sets { display: grid; gap: 6px; border: 1px solid #c8dde3; border-radius: 6px; }
.pg-generate { padding: 10px; font: inherit; font-weight: bold; color: #fff; background: #067a98; border: 0; border-radius: 6px; cursor: pointer; }
.pg-generate:disabled { background: #9aa5ab; cursor: not-allowed; }
.pg-hint { margin: 0; color: #b03050; }
.pg-result { display: flex; gap: 8px; }
.pg-result input { flex: 1; min-width: 0; padding: 8px; font: 16px ui-monospace, monospace; background: #eefafd; border: 1px solid #c8dde3; border-radius: 6px; }
.pg-bits { margin: 0; color: #067a98; }

検証環境で確かめた動き

Playwright で開発用のビルドを開き、ボタンを押して確かめました。最初の3回で出たパスワードは次のとおりです。

text
1回目: r471TAh?aIP3fZ%+(16文字、小大数記=1111)
2回目: T8?EMMDlXE8GqKSC(16文字、小大数記=1111)
3回目: rp=lPC84q*jPf1&c(16文字、小大数記=1111)

続けて200回押したところ、200個すべてが16文字で4種類の文字を含み、同じパスワードは1つもありませんでした。長さ4にして100回押したときも、すべて4種類がそろっていました。

「見分けにくい文字を使わない」に切り替えて300回押すと、I、l、1、O、0、o を含むパスワードは0個でした。比べるために切り替えを戻して300回押すと、300個のうち224個に、これらの文字のどれかが入っていました。

組み合わせの数の表示は目安にとどめる

部品の下に出している「組み合わせの数:約 299 通り」は、使う文字の数(74)を、長さ(16)の回数だけ掛け合わせた数を、2の何乗かで表し、小数点以下を切り捨てたものです。2の何乗かで表すと、長さや文字の種類を変えたときの差が見やすくなります。GIF のように記号を外すと、74文字が62文字に減るので、299 から 295 に下がります。

ただし、この部品は4種類がそろわないパスワードを捨てているので、実際に出るパスワードの数は、すべての並びの数(長さ16なら 7416、約 299.35)より少なくなります。包除原理で数えた、4種類を含む並びの数と比べました。

長さ すべての並び 表示(切り捨て) 4種類を含む並び
4 224.84 224 220.89
8 249.68 249 248.54
16 299.35 299 299.11
32 2198.70 2198 2198.68

長さ16以上では、4種類がそろわない並びを捨てて減る分が、表示で切り捨てた小数点以下より小さいので、表示のほうがわずかに小さくなりました。短いパスワードほど減り方が大きく、長さ4では表示より約3小さくなります。入力したパスワードの強さを、よくある単語や並びまで含めて判定したいときは、以前の記事「Reactで、入力したパスワードの強さを表示する方法」で使った zxcvbn-ts のようなライブラリが向いています。

​Posted by 86