お手軽さを重視しているため、作り方に偏りがある事が分かっている物もある。
以下の環境で確認
- Node: v10.15.0
- ブラウザ: Chrome 71.0.3578.98, Firefox 64.0.2
Edgeやモバイルの動作確認はしてない。
2022/04/09 追記: Math.randomは注意して使ってください
今更ですが。
Math.randomを使って合言葉や仮パスワードを生成するケースを見かけたので追記しておきます。
Math.randomは暗号論的擬似乱数を満たさない場合があります。つまり安全な実装ではない可能性があるため、そこから得られた乱数は安全ではないといえます。
バレても実用上影響がないと判断した場合は構いませんが、そうでない場合は実装を隠して生成される値を推測困難にしたり、実装を外部から使わせないようにして推測されても問題ない形にする(仮パスワード発行は管理者しか行えない等)などの策を講じた上で使用してください。
Node/ブラウザ共通
短いコードで
[a-v]
と[0-9]
の32文字で12桁以下なら、これでそれっぽいのが作れる。StackOverflowで知った。
しかし、末端の0がくると省略されるため、任意の桁数が得られるとは限らない。以下のコードでも100万回ぐらい生成を繰り返すと6桁のものがでてきたりする。
Math.random().toString(32).substring(2) // 'a6dpgjqlq8g' 等
任意の桁数・任意の文字で
var S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var N=16
Array.from(Array(N)).map(()=>S[Math.floor(Math.random()*S.length)]).join('')
一番最初に考えたやり方がこれだった。
なお、Array(N)
だけでは長さNの配列になっているが要素が無い状態のため、mapが機能しないらしい。
そのため、Array.from(Array(N))
としてやることで、undefinedな要素が入りmapが機能するようになる。(ES6以降なら、Array.from
の代わりに[...Array(N)]
が使える。以降はES6/ES5ごちゃまぜになっている。)
ブラウザ
よりセキュアな乱数生成器で
「Math.randomはセキュアではないのでは?」という難癖をつける場合、crypto.getRandomValues
があるのでこちらを使う。結構古いブラウザもサポートしてる模様。IE11もmsCrypto
から使える。
以下の方法は除算を使うので特定の文字に偏りが起きる。なのでセキュア志向なら使ったらダメ。(以下の例だと[a-h]
が出やすい)
var S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var N=16
Array.from(crypto.getRandomValues(new Uint8Array(N))).map((n)=>S[n%S.length]).join('')
なお、Uint8Array
からUint32Array
にすると偏りは少しはマシになる。後述。
これよりも任意の文字を偏りなく出す方法を他のAPIで出来ないか考えたけど、すぐに思い浮かばなかったので諦めてます。
Base64で
文字を用意するのが面倒な場合、String.fromCharCode
で変換してからbtoa
でBase64変換を使う手がある。
複雑な記号は使えないが、除算を使わない分こっちのほうが偏りも少ない気がする。(未調査)
var N=16
btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(N)))).substring(0,N)
Nodeのみ
よりセキュアな乱数生成器で
Nodeにはブラウザとは異なるAPIを持つcrypto
モジュールがある。
ブラウザのcrypto.getRandomValues
と同じ動作をするcrypto.randomFillSync
があるのでこれで。
以下の方法も同じように除算を使ってるので偏ります。
const crypto = require('crypto')
const S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const N=16
Array.from(crypto.randomFillSync(new Uint8Array(N))).map((n)=>S[n%S.length]).join('')
Base64で
Base64も同じ要領で...と思ったが、btoaがないので、ブラウザのようには行えない。
代わりにNodeにはcrypto.randomBytes
がある。これはN個のランダムなバイト列を作り、Node固有のBufferという型で返す。(crypto.randomFillSync(new Uint8Array(N))
に似ているが、戻り値の型が違う。)
そして、このBufferがBase64変換に対応している。なので、もう少し短く書ける。
const crypto = require('crypto')
const N = 16
crypto.randomBytes(N).toString('base64').substring(0, N)
Nodeはこれが一番スマートな気がする。
おまけ:剰余計算による偏り
質の良い乱数生成器を使ったとしても、剰余を求めるやり方を行ってしまうと、偏りが起きる場合がある。
const S="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // 62文字
let p=Array(S.length).fill(0)
// 100個づつ乱数を取り出し集計する処理を6200回繰り返す(620000回行う)
for(let i=0;i<100*S.length; ++i){
const arr = crypto.randomFillSync(new Uint8Array(100))
for(let j=0; j<arr.length; ++j){
p[arr[j]%S.length]++;
}
}
p // => 均等にばらければ全部が10000前後になるはずだが、あきらかに0-7番目の要素のカウントが大きいことが分かる。
これは、0~255(256個)がランダムで得られたとしても、剰余計算によって0~61(62個)のいずれかになる。
すると、0~61,62~123, 124~185,186~247の間は均等に出現するが、残りの248~255は0~7しか出現しえないため、この分が他と比べて多く出る。これで偏りが起きる。
逆に256を割り切れる数(2,4,8...64,128)で割れば計算による偏りは起きない。もし偏る場合は元の乱数の問題。
また、ランダムで得られる範囲0-255と狭いせいというのもあるので、この範囲を十分に大きくすると剰余計算による偏りは減らすことができる。
上記のコードなら、Uint8Array
=> Uint32Array
にしてみると、偏りが目立たなくなることがわかる。