JavaScriptでパスワードを生成する方法をまとめます。当サイトのパスワード生成ツールで使っている処理です。
乱数にはMath.random()ではなくcrypto.getRandomValues()を使います。ただ、それだけでは足りない点が2つあります。乱数を文字数で割った余り(%)で文字を選ぶと、文字によって出やすさに差が出ます。また、「英大文字・数字・記号を必ず1文字以上入れる」処理は、書き方によって先頭の文字が決まってしまうなどの偏りが出ます。乱数はcrypto.getRandomValues()で取り、範囲からはみ出した値は捨てて引き直し、「必ず1文字」は条件を満たすまで全体を作り直すのが、偏りの出ないやり方です。この記事では、それぞれの偏りを実際に数えて確かめます。

使っているもの
ライブラリはありません。ブラウザ標準のcrypto.getRandomValues()とUint32Arrayだけです。以下のコードと数値は、Node.js 22(V8 12.4)で実際に動かして確かめたものです。Node.js 22では、ブラウザと同じcrypto.getRandomValues()がそのまま使えます。
Math.randomを使わない理由は「偏り」ではなく「予測」
Math.random()の出る値は、統計的にはよくばらついています。問題は、次に出る値を予測できることです。MDNは、Math.random()は暗号学的に安全な乱数ではないので、セキュリティに関わる用途には使わず、crypto.getRandomValues()を使うように書いています。
ChromeやNode.jsのJavaScriptエンジン(V8)は、Chrome 49以降、Math.random()にxorshift128+という計算方法を使っています。V8の開発チームは、2015年12月にこの変更を発表した記事で、xorshift128+も「暗号学的に安全ではない」と明記しています。xorshift128+は、出力が内部の状態から単純な計算で決まるため、続けて出た値をいくつか集めると、内部の状態を逆算して以降の値を予測できます。V8の場合、続けて出た64個の値から状態を割り出す研究コードが公開されています。パスワードを作る処理で使うと、同じページで作られたほかの値から、パスワードが推測されるおそれがあります。
crypto.getRandomValues()は、OSの乱数(Unixの/dev/urandomなど)を種にした、暗号用途に使える疑似乱数を返します。HTTPSではないページでも使える点も便利です(crypto.randomUUID()やcrypto.subtleは、HTTPSのページなどの安全なコンテキストでしか使えません)。
まず、よく見かける書き方
乱数のバイト列を作り、文字の数(ここでは62)で割った余りで文字を選ぶ書き方です。
const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function naivePassword(len) {
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
let s = '';
for (let i = 0; i < len; i++) s += CHARS[bytes[i] % CHARS.length];
return s;
}
naivePassword(16); // → 例: "h4Zm8nxzZfYzqQe8"
乱数は安全なものを使っていますが、この書き方では、文字によって出やすさが変わります。
「%」で選ぶと、先頭の8文字が1.25倍出やすい
Uint8Arrayの値は0〜255の256通りです。256を62で割ると、4余り8です。つまり、余りが0〜7になる値は5通りずつ、8〜61になる値は4通りずつあります。CHARSの先頭8文字(A〜H)だけが、ほかの文字より1.25倍出やすくなります。
実際に1,240万文字を作って数えました。
const count = {};
for (let k = 0; k < 200; k++) {
for (const c of naivePassword(62000)) count[c] = (count[c] || 0) + 1;
}
// 200回 × 62,000文字 = 1,240万文字
| 文字 | 計算上の出やすさ | 実測(1,240万文字) |
|---|---|---|
| A〜H(8文字) | 5/256=1.953% | 1.949〜1.962% |
| I〜9(54文字) | 4/256=1.563% | 1.551〜1.571% |
計算どおり、A〜Hだけが出やすくなりました。攻撃する側は、出やすい文字から順に試すことで、少しだけ早く当てられます。
Uint8ArrayをUint32Arrayに変えると、値は約43億通りになり、差はとても小さくなります。2の32乗を62で割った余りは4なので、出やすい4文字の出る確率は、ほかの文字の約1.000000014倍です(約6,900万分の1だけ高い)。実用上は無視できる大きさですが、次の書き方にすれば偏りそのものをなくせます。
偏りを消す:はみ出した値は捨てて引き直す
割り切れない端の部分が偏りの原因なので、端に入った値は使わずに、もう一度乱数を取ります。「棄却サンプリング」と呼ばれる方法です。ツールで使っている関数は次のとおりです(読みやすく書き直しています)。
// 0以上 n未満の整数を、偏りなく返す
function randomInt(n) {
const buf = new Uint32Array(1);
const limit = Math.floor(2 ** 32 / n) * n; // nの倍数のうち、2の32乗以下で最大の数
do {
crypto.getRandomValues(buf);
} while (buf[0] >= limit); // はみ出した値は捨てて引き直す
return buf[0] % n;
}
function randomPassword(len, chars) {
let s = '';
for (let i = 0; i < len; i++) s += chars[randomInt(chars.length)];
return s;
}
randomPassword(16, CHARS); // → 例: "QPdwSVHDbroYbn7H"
limitより小さい値だけを使えば、0〜limit−1には、どの余りもちょうど同じ数ずつ含まれます。同じく1,240万文字を作って数えると、62文字すべてが1.604〜1.622%に収まりました(計算上は1/62=1.613%)。
引き直しが起きる確率は、Uint32Arrayならとても小さく、文字が62種類なら約10億回に1回です。Uint8Arrayで同じことをすると、limitは248になり、約3.1%(256回に8回)の確率で引き直します。どちらでも結果は同じなので、コードを短くしたいならUint32Arrayで1文字ずつ取る書き方で十分です。

「必ず1文字ずつ入れる」は、全体を作り直す
サイトによっては「英大文字・英小文字・数字・記号をそれぞれ1文字以上」と決められています。ツールには「選んだ種類を必ず1文字以上入れる」という選択肢があり、ここにも書き方による違いが出ます。
よく見かけるのは、先頭に種類ごとの文字を1つずつ置き、残りを全体から埋める書き方です。並べ替えないと先頭が必ず英小文字になるので、並べ替えを足します。その並べ替えにsort()とMath.random()を使う例も多くあります。
const SETS = ['abcdefghijkmnpqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!#$%*+-=?@^_~'];
// 先頭に種類ごとの文字を1つずつ置き、残りを全体から埋める
function headFirst(len) {
const chars = SETS.join('');
const a = SETS.map(set => set[randomInt(set.length)]);
for (let i = SETS.length; i < len; i++) a.push(chars[randomInt(chars.length)]);
return a;
}
headFirst(16).join(''); // 並べ替えなし
headFirst(16).sort(() => Math.random() - 0.5).join(''); // sortで並べ替え
ツールでは、全体から普通に作り、条件を満たしていなければ最初から作り直しています。
function passwordWithEach(len, sets) {
const chars = sets.join('');
for (;;) {
const s = randomPassword(len, chars);
const ok = sets.every(set => [...s].some(c => set.includes(c)));
if (ok) return s; // 条件を満たしたものだけを返す
}
}
passwordWithEach(16, SETS); // → 例: "sXRzsJk7@q=a+BNL"
ツールの初期設定と同じ文字(紛らわしい文字を除いた英小文字24・英大文字24・数字8・記号13の計69文字)で、やり方ごとに8文字のパスワードを40万個ずつ作り、1文字目の種類を数えました。
| やり方 | 1文字目が英小文字 | 英大文字 | 数字 | 記号 |
|---|---|---|---|---|
| 全体を作り直す(ツール) | 30.6% | 30.4% | 17.7% | 21.3% |
| 先頭に置く・並べ替えなし | 100% | 0% | 0% | 0% |
| 先頭に置く・sortで並べ替え | 37.4% | 23.6% | 16.1% | 22.9% |
| 先頭に置く・正しく並べ替え(後述) | 29.9% | 30.0% | 18.2% | 22.0% |
並べ替えなしでは、1文字目が必ず英小文字、2文字目が必ず英大文字…と決まってしまいます。sort()で並べ替えても、1文字目が英小文字になる割合は37.4%で、全体を作り直す場合(30.6%)より高く、英大文字は23.6%と低くなりました。先頭に置いた順番が、並べ替えたあとも残っているということです。
sort()は、比較関数がいつも同じ答えを返すことを前提にした仕組みです。毎回ランダムな答えを返すと、並び方はエンジンの並べ替えの実装しだいになり、均等には混ざりません。上の数字もNode.js 22(V8)での結果で、ほかのエンジンでは偏り方が変わります。
並べ替えるなら、次のフィッシャー–イェーツのシャッフルを使います。後ろから順に、自分より前(自分を含む)のどこかと入れ替えるだけです。
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = randomInt(i + 1); // 0〜iのどれか
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
shuffle(headFirst(16)).join('');
これで1文字目の偏りは消えます。ただし、表のとおり、数字や記号が少し多めに出ます(8文字全体で数えた数字の割合は、作り直す方式で17.6%、この方式で18.3%)。種類ごとに1文字を先に確保してから残りを埋めるので、数の少ない種類(数字や記号)が、条件を満たす組み合わせ全体での割合より多めに入るためです。全体を作り直す方式なら、「条件を満たすパスワード」の中から、どれも同じ確率で選ばれます。
作り直す回数は多くありません。初期設定の69文字の場合、条件を1回で満たす確率は、8文字で約45%、16文字で約83%です(40万個を作った実測では、1個あたり平均2.25回と1.21回)。ツールでは念のため、200回作り直しても条件を満たさないときは、そのときの文字列を返すようにしています。ツールで選べるいちばん短い8文字で、記号を1種類だけにした厳しい設定でも、200回続けて満たさない確率は1,000万回に1回もありません。
強さ(ビット)の計算
ツールの「強さの目安」は、情報量をビットで表しています。文字の種類がc種類で長さがLなら、組み合わせはcのL乗通りで、ビットにするとL×log2(c)です。
const bits = len * Math.log2(chars.length);
// 1秒に100億回試す相手が、平均して半分を試したところで当たると考える
const seconds = 2 ** (bits - 1) / 1e10;
初期設定の69文字なら、1文字あたり約6.1ビットです。
| 長さ | 情報量 | 「必ず1文字」のとき | 当たるまでの平均時間 |
|---|---|---|---|
| 8文字 | 48.87ビット | 47.70ビット | 約7時間 |
| 12文字 | 73.30ビット | 72.77ビット | 約2万年 |
| 16文字 | 97.74ビット | 97.46ビット | 約4,183億年 |
「必ず1文字」にすると、条件を満たさない組み合わせが除かれるので、情報量は少し減ります。表の3列目は、条件を満たす組み合わせの数を、包除原理(重なりを足し引きして数える方法)で数えて計算した値です。8文字では約1.2ビット、16文字では約0.3ビット減ります。ツールの表示は、この差を入れていない2列目の値です。平均時間の列も2列目の値から計算しています。
「覚えやすい語の組み合わせ」は、単語のリストから選ぶので、1語あたりlog2(単語の数)ビットです。英単語519語なら1語あたり約9.0ビットで、4語で約36ビットです。数字2けた(00〜99の100通り)を5か所のどこかに入れると、log2(100)+log2(5)で約9.0ビット増えて、約45ビットになります。ツールでは同じ単語を2回使わないようにしているので、厳密にはごくわずかに減ります(4語で0.02ビット)。頭文字を大文字にする処理は、どの語にも必ずかかるので、情報量は増えません。
はまりどころ
その1:記号の欄に同じ文字が2回あると、その文字が2倍出る
ツールでは、使う記号を自由に書き換えられます。入力された文字列をそのまま候補に加えると、「!!#」のように同じ記号が2回あった場合、「!」だけが2倍出やすくなります。ツールでは、候補にする前に、重複した文字と半角スペースを取り除いています。
function uniqChars(s) {
let out = '';
for (const c of s) {
if (c !== ' ' && !out.includes(c)) out += c;
}
return out;
}
uniqChars('!! # $'); // → "!#$"
その2:紛らわしい文字を外すと、1文字あたりの情報量が減る
小文字のエル(l)と大文字のアイ(I)と数字の1、大文字のオー(O)と数字の0、小文字のオー(o)を外すと、英数字は62文字から56文字になります。1文字あたりの情報量は5.95ビットから5.81ビットに下がります。16文字なら約2.3ビットの差なので、気になる場合は1文字長くすれば十分に補えます。
その3:一度に取れる乱数は65,536バイトまで
crypto.getRandomValues()に渡せる配列は65,536バイトまでで、超えるとQuotaExceededErrorになります。Uint32Arrayなら16,384個です。パスワードを1つ作るだけなら問題になりませんが、大量に作るときや、上の実測のように何百万文字も数えるときは、分けて取ります。

まとめ
パスワードの乱数には、予測できないcrypto.getRandomValues()を使います。Math.random()の問題は偏りではなく、出力から次の値を予測できることです。文字を選ぶときに余り(%)をそのまま使うと、割り切れない端の文字が出やすくなるので、はみ出した値は捨てて引き直します。「種類ごとに必ず1文字」は、全体を作り直す方式にすると偏りが出ません。先頭に置いてから並べ替える場合は、sort()ではなくフィッシャー–イェーツのシャッフルを使います。
この処理で動いているのがパスワード生成ツールです。長さや使う記号を選んで、ブラウザの中だけでパスワードを作れます。
参考: MDN: Crypto.getRandomValues()/MDN: Math.random()/V8: There’s Math.random(), and then there’s Math.random()(2015年)/js-rng-state-recovery(Math.randomの内部状態の復元)

