JavaScriptでパスワードを生成する方法|crypto.getRandomValuesと偏りの消し方

パスワードをJSで生成 — Math.randomを使わない理由(トットの開発メモ)

JavaScriptでパスワードを生成する方法をまとめます。当サイトのパスワード生成ツールで使っている処理です。

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

トット
トット
Math.randomでも、ちゃんとバラバラに出るよね?

使っているもの

ライブラリはありません。ブラウザ標準の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つ作るだけなら問題になりませんが、大量に作るときや、上の実測のように何百万文字も数えるときは、分けて取ります。

トット
トット
乱数は安全なものを使って、はみ出したら引き直して、条件は作り直しで満たす。この3つだね。

まとめ

パスワードの乱数には、予測できない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の内部状態の復元)

あわせてどうぞ

タイトルとURLをコピーしました