|||||||||||||||||||||

なんぶ電子

- 更新: 

ブラウザのJSでセキュアなランダム文字列を生成したい

GetRandomStr

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));

参考にさせていただきましたサイトの皆様、ありがとうございました。

筆者紹介


自分の写真
がーふぁ、とか、ふぃんてっく、とか世の中すっかりハイテクになってしまいました。プログラムのコーディングに触れることもある筆者ですが、自分の作業は硯と筆で文字をかいているみたいな古臭いものだと思っています。 今やこんな風にブログを書くことすらAIにとって代わられそうなほど技術は進んでいます。 生活やビジネスでPCを活用しようとするとき、そんな第一線の技術と比べてしまうとやる気が失せてしまいがちですが、おいしいお惣菜をネットで注文できる時代でも、手作りの味はすたれていません。 提示されたもの(アプリ)に自分を合わせるのでなく、自分の活動にあったアプリを作る。それがPC活用の基本なんじゃなかと思います。 そんな意見に同調していただける方向けにLinuxのDebianOSをはじめとした基本無料のアプリの使い方を紹介できたらなと考えています。

広告