Help us understand the problem. What is going on with this article?

Pure JavaScript 高速 SHA-256 ハッシュ実装

SHA-256 のダイジェスト・ハッシュ値を計算する高速なピュア JavaScript 実装のライブラリを npm で公開したので紹介します。
Uint8ArrayInt32Array を使うことで、このほかのピュア JavaScript 実装のライブラリよりも高速にハッシュ値を計算します。

[1/20 追記]
本実装は Node.js の require("crypto").createHash() よりは遅いが、ブラウザのネイティブ crypto.subtle.digest() よりも条件によっては速い模様。

SHA-2 (SHA-256) 版

SHA-256 は、256ビット(16進数64桁)のハッシュ値です。
例:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

https://www.npmjs.com/package/sha256-uint8array

const createHash = require("sha256-uint8array").createHash;

const text = "";
const hex = createHash().update(text).digest("hex");
// => "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

const data = new Uint8Array(0);
const hash = createHash().update(data).digest();
// => <Uint8Array e3 b0 c4 42 98 fc 1c 14 9a fb f4 c8 99 6f b9 24 27 ae 41 e4 64 9b 93 4c a4 95 99 1b 78 52 b8 55>

インターフェースは、Node.js の ネイティブの crypto モジュールのサブセットです。
v0.9.0 時点のベンチマーク結果は以下の通り。(macOS 10.15.7 Intel Core i7 3.2GHz)
1KB 程度の JSON と日本語文字列を、各 10,000 回ずつハッシュ計算したときのミリ秒です。

module version node.js V14 Chrome 87 Safari 14 minified backend
crypto - 103ms 👍 - - - OpenSSL
sha256-uint8array 0.9.0 274ms 446ms 👍 243ms 👍 3KB 👍 Uint8Array
crypto-js 4.0.0 805ms 910ms 918ms 108KB Uint8Array
jssha 3.2.0 835ms 892ms 913ms 10KB Uint8Array
hash.js 1.1.7 635ms 611ms 1,577ms 7KB Array
sha.js 2.4.11 356ms 965ms 3,512ms 27KB Buffer
create-hash 1.2.0 381ms 1,002ms 3,502ms 97KB Buffer
jshashes 1.0.8 1,450ms 2,239ms 1,164ms 23KB Array

1回あたりのハッシュ計算にかかる時間は、マイクロ秒単位なので、いろんな用途で使えそうです。
そのほかの特徴:

  • 入力は、文字列または Uint8Array
  • 出力は、16進数文字列または Uint8Array
  • 複数チャンクに分割された入力にも対応しています。
  • 絵文字など U+10000 以降のサロゲートペア、UTF-8 で4バイトの文字に対応しています。
  • IE11 でも動きます。(IE10 ですら動くようだ)

SHA-1 版

同じインターフェースで SHA-1 版もあります。
SHA-1 は、160ビット(16進数40桁)のハッシュ値です。
例:da39a3ee5e6b4b0d3255bfef95601890afd80709

https://www.npmjs.com/package/sha1-uint8array

const createHash = require("sha1-uint8array").createHash;

const text = "";
const hex = createHash().update(text).digest("hex");
// => "da39a3ee5e6b4b0d3255bfef95601890afd80709"

const data = new Uint8Array(0);
const hash = createHash().update(data).digest();
// => <Uint8Array da 39 a3 ee 5e 6b 4b 0d 32 55 bf ef 95 60 18 90 af d8 07 09>

v0.9.0 時点のベンチマーク結果は以下の通り。

module version node.js V14 Chrome 87 Safari 14 minified backend
crypto - 70ms 👍 - - - OpenSSL
sha1-uint8array 0.9.0 218ms 346ms 👍 192ms 👍 2KB 👍 Uint8Array
hash.js 1.1.7 513ms 573ms 908ms 7KB Array
jssha 3.2.0 690ms 782ms 770ms 9KB Uint8Array
crypto-js 4.0.0 779ms 829ms 961ms 108KB Uint8Array
jshashes 1.0.8 686ms 1,448ms 727ms 23KB Array
tiny-sha1 0.2.1 209ms 775ms 3,573ms 2KB Uint8Array
sha.js 2.4.11 360ms 930ms 3,534ms 26KB Buffer
create-hash 1.2.0 387ms 976ms 3,591ms 97KB Buffer

古いライブラリだと ASCII 文字列のみ対応で、日本語すら使えないものもあったので、注意が必要。
上記リストの各ライブラリごとの minified したときの容量や、バックエンド(内部で使われる仕組み)は、ざっくり調査したものなので、厳密には違うかも。

高速化実装のキモ

JavaScript の実行を高速化するには、できるだけオブジェクトを作らない・メモリを確保しない実装が大切です。
V8 なら、確保されたメモリ内で済む処理ならば、かなり高速に動作してくれます。

本ライブラリでは、1回のハッシュ値計算ごとに、3つオブジェクトを作っています。
もし複数チャンク分割に対応せずに、1入力だけとすれば、再入を考慮せずに済むので、さらに減らせるのだが。
メモリは 8KB のプールを分割して利用することで、毎回はメモリを確保しないようにしています。

また、文字列からの入力時は、JavaScript の内部の文字コードは UTF-16 なので、UTF-8 への変換が必要。
文字列全体をいちどに TypedArray に展開するのではなくて、64・80バイト毎のブロックの範囲ごとに変換しています。
どちらかというと、この処理を書いてみたくて、このライブラリを作ったようなものだ。

ネイティブ実装との速度比較

上記のベンチマークの通り、ほかのピュア JavaScript 実装のライブラリよりは高速なものの、
Node.js だけで使うなら、ネイティブの crypto モジュールを使ったほうが3倍くらい速いです。
内部で使われる OpenSSL の実装がカリカリで速すぎるみたい。

なお、ブラウザでは、TextEncodercrypto.subtle.digest() の両方が使える環境であれば、その方が速いです。 iOS 10.3 以降あるいは Android 5 以降かつ、HTTPS サーバ配下なら使えるようです。IE や古い方の Edge では使えない。
た。

Node.js の crypto とブラウザの crypto.subtle はインターフェースが異なるので注意が必要。ブラウザでは、即値ではなく Promise を返したり、まだチャンク分割入力できず、出力は ArrayBuffer になる等の違いがある。

[1/20 追記]
1KB 程度のテキストやバイナリ入力 → hex 出力までのベンチマークを取ったところ、ブラウザの crypto.subtle.digest() よりも本実装の方が速い模様。

Browserify

crypto モジュール全体を呼んだアプリを、ブラウザ向けに普通に browserify すると、minify した後でも +300KB 超の容量増になります。
あるいは大きめのライブラリを使うと 100KB になるところ、本ライブラリなら 3KB 前後と、コンパクトに利用できます。
もし、crypto モジュールを crypto.createHash("sha256").update(data).digest("hex"); 形式のハッシュ計算でしか使っていないアプリなら、下記のような browserify 設定にすることで、『Node.js ならネイティブ実装』・『ブラウザなら本ライブラリ』と切り替えて利用することができます。

package.json
{
  "browser": {
    "crypto": "sha256-uint8array/dist/sha256-uint8array.min.js"
  },
  "devDependencies": {
    "browserify": "^17.0.0",
    "sha256-uint8array": "^0.9.0",
    "terser": "^5.5.1"
  }
}

(備考)どのハッシュを使うか

ハッシュはいくつも種類があります。その昔は軽い MD5 を使っていました。

  • MD5 は弱いので、現代のサービスでは使われないです。
  • SHA-1 もすでに推奨されていないものの、用途によっては、まだまだみかけます。
  • SHA-256 を使うと SHA-1 よりも2〜3割ほど遅くなるものの、大量に使う用途でもなければ、無視できるレベルの差かなと。
  • SHA-368 や SHA-512 については、64ビット整数演算が必要なので、ピュア JavaScript で実用的な速度で実装するのは難しそう。

ネイティブ実装と、ピュア JavaScript 実装の両方を混在利用したい場合は、今なら SHA-256 を使うのが良さそう。

kawanet
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away