ブラウザのJSでセキュアなランダム文字列を生成したい
JavaScriptでMath.random()を使ってランダムな文字列を発生させたとき、「この長さでなんで同じ文字がこんなに出現するのだろう」などと気になったので、もう少し精度の高い生成器を使って文字列を作成してみました。
セキュアな乱数生成器はブラウザのJavaScriptと、CommonJSではライブラリが違います。ここでは主にブラウザで実装されているWindow.cryptoについて書いていますが、最後にNode.jsのCryptoについても少しだけ触れています。
Math.random()
JavaScriptで乱数を利用する際は、Math.random()を利用するのが一般的だと思います。
Math.random()は0以上1未満の浮動小数点を返すので、例えば0~10の値が欲しかったら、Math.random()で得られた値に11を掛けて切り捨てすることで望む値が得られます。
ただ、先のリンク先であるMDNのページには、Math.random()は「乱数」ではなく「疑似乱数」という表現を使っています。
どういうことかというと、これで生成する値は「初期シード」という値に依存し、同じ初期シードのMath.random()は毎回同じ順序で値を発行します。つまり、初期シードを知っていれば次の値がわかるというということです。
JavaScriptではこの初期シードをユーザーが決めることができませんし、実装も環境によって違ったりもするので、一般的なプログラミングに利用する場合には問題ないと思います。
Crypto.getRandomValues()
暗号的な強度が必要な乱数を使用したい場合にはCrypto.getRandomValues()を使います。
ただし、先に頭にいれておきたいのは、ブラウザのJavaScriptはクライアントのPCで稼働するということです。ユーザー側の操作でコードが加工されるかもしれませんし、ユーザーのブラウザの内容が盗聴されているかもしれません。他にも理由があるようですが、そのためMDNは非セキュアコンテキスト(SSL外)で動くgetRandomValues()を暗号鍵の生成に使わないように言っています。
また、これで得られる値は「暗号強度の強い乱数値」ですが、「疑似乱数」であることには変わりはありません。
getRandomValues()で実装
getRandomValues()を使ってランダムな文字列を生成するコードを組んでみました。
<script>
class GetRandomStr {
//使用したい文字をセット
CHARACTERS ="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890";
//全文字の長さ(ワーク用)
CHARACTERS_LENGTH = this.CHARACTERS.length;
//Uint8の最大値 乱数を受け取る入れ物をUint32にするなら4294967295
RANDOM_VALUE_MAX = 255;
//乱数の有効値の範囲 これより大きい値がでたら切り捨てる(上記文字列の出現確率を均等にするため)
VALID_RANGE = Math.floor((this.RANDOM_VALUE_MAX+1) / this.CHARACTERS_LENGTH) * this.CHARACTERS_LENGTH - 1;
getRandomStr(intLength) {
//ランダムな文字列を取得する
let ret = '';
//文字の候補が乱数の最大値より大きかったらエラーとして空白を返す
if (this.RANDOM_VALUE_MAX < this.CHARACTERS_LENGTH) {
return '';
}
if(intLength <= 0) {
return '';
}
while(ret.length < intLength) {
//乱数の入れ物(有効範囲を超えた場合切り捨てるので2倍の数取得しています)
//得られる値が65536バイトを超えるとgetRandomValues()側でエラーになります
const arr = new Uint8Array(intLength * 2);
//乱数取得
crypto.getRandomValues(arr);
//文字生成
for(let i = 0; i < arr.length; i++) {
//乱数有効長チェック
if(this.VALID_RANGE < arr[i]) {
continue;
}
ret+=this.CHARACTERS[arr[i] % this.CHARACTERS_LENGTH];
}
}
return(ret.substring(0,intLength));
}
getRandomHex(intHexLength,blnUpper=false) {
//16進の値を取得する
//16進数の文字列ならUint8Arrayの値そのままで生成できます
//intHexLengthは16進の単位で指定してください(文字長は倍になります)
let ret = '';
if(intHexLength <= 0) {
return '';
}
const arr = new Uint8Array(intHexLength);
//乱数取得
crypto.getRandomValues(arr);
for(let i = 0; i < arr.length; i++) {
ret +=arr[i].toString(16);
}
if (blnUpper) {
return ret.toUpperCase();
} else {
return ret;
}
}
}
//使用例
let r = new GetRandomStr();
console.log(r.getRandomStr(10));
console.log(r.getRandomHex(10));
</script>
crypto.getRandomValues()は引数として受け取った配列にランダムな値をセットする関数です。引数には整数値のTypedArray(型付配列)をセットします。
具体的には、
- Int8Array,Uint8Array
- Int16Array,Uint16Array
- Int32Array,Uint32Array
のいずれかになると思います。今回は文字列に変換すする都合や、getRandomValues()に渡せる最大サイズが65,536バイトに限られることから、「Uint8Array」を使いました。
JavaScriptの配列については別記事もありますので、合わせて読んでいただければ幸いです。
Quiita:「JavaScriptでお手軽にランダム文字列の生成」によれば、使用したい文字数と得られる値の数が一致しない場合は得られる値に偏りが生まれるということなので、使用する文字数で割り切れる最大値までを乱数の有効値として、それ以上の値を得た場合は値を破棄します。筆者は数学に疎いのでこれで偏りがなくなるのかよくわかりませんが……。
Angularで利用する
筆者がランダムな文字列の生成を実装しようと思ったきっかけのもうひとつに、Angularでcrypto周りのライブラリを使おうとすると、コンパイルエラーがでることがあります。
CommonJSのcryptoはWebでは使えませんし、Webを実装しているnpmのライブラリでは、Angularのコンパイルが通りませんでした。(調べたnpmパッケージは一部だけですので、中にはコンパイルを通過するものもあるかもしれません。)
Angularで今回のコードを実装すると次のような感じになります。
get-random-str.ts
export class GetRandomStr {
private readonly CHARACTERS ="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890";
private readonly CHARACTERS_LENGTH = this.CHARACTERS.length;
private readonly RANDOM_VALUE_MAX = 255;
private readonly VALID_RANGE = Math.floor((this.RANDOM_VALUE_MAX+1) / this.CHARACTERS_LENGTH) * this.CHARACTERS_LENGTH - 1;
private readonly crypto = window.crypto ? window.crypto : null;
public getRandomStr(intLength:number):string {
let ret = '';
if (this.RANDOM_VALUE_MAX < this.CHARACTERS_LENGTH) {
return '';
}
if(intLength <= 0) {
return '';
}
while(ret.length < intLength) {
const arr = new Uint8Array(intLength * 2);
if (this.crypto) {
this.crypto.getRandomValues(arr);
}
for(let i = 0; i < arr.length; i++) {
if(this.VALID_RANGE < arr[i]) {
continue;
}
ret+=this.CHARACTERS[arr[i] % this.CHARACTERS_LENGTH];
}
}
return(ret.substring(0,intLength));
}
public getRandomHex(intHexLength:number,blnUpper:boolean=false) {
let ret = '';
if(intHexLength <= 0) {
return '';
}
const arr = new Uint8Array(intHexLength);
if (this.crypto) {
this.crypto.getRandomValues(arr);
}
for(let i = 0; i < arr.length; i++) {
ret +=arr[i].toString(16);
}
if (blnUpper) {
return ret.toUpperCase();
} else {
return ret;
}
}
}
作成したクラスファイルをコンポーネントからimportして使います。
some.component.ts
...
import { GetRandomStr } from '../get-random-str';
...
let rstr = new GetRandomStr();
let strRandom = rstr.getRandomStr(12);
...
window.cryptoが取得できなかった時のことは考えていません。Uint8Arrayのインスタンス生成時に配列はすべて0で初期化されますので、そのような場合は指定した長さの0の連続が返ります。
Node.jsのcryptoを利用する
Node.jsからはcrypto.webcrypto.getRandomValues(typedArray)が使えます。また、バージョン17.4.0からはcrypto.getRandomValues(typedArray)としても使えるようですがこちらはWebの仕様と互換がないそうです。
const crypto = require('crypto');
let u = new Uint8Array(10);
console.log(crypto.webcrypto.getRandomValues(u));
参考にさせていただきましたサイトの皆様、ありがとうございました。