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

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

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

## SHA-2 (SHA-256) 版

[SHA-256](https://ja.wikipedia.org/wiki/SHA-2) は、256ビット（16進数64桁）のハッシュ値です。
例：`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`

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


```js
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](https://nodejs.org/api/crypto.html) モジュールのサブセットです。
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](https://nodejs.org/api/crypto.html)|-|103ms 👍|-|-|-|OpenSSL|
|[sha256-uint8array](http://github.com/kawanet/sha256-uint8array)|0.9.0|274ms|446ms 👍|243ms 👍|3KB 👍|Uint8Array|
|[crypto-js](https://npmjs.com/package/crypto-js)|4.0.0|805ms|910ms|918ms|108KB|Uint8Array|
|[jssha](https://npmjs.com/package/jssha)|3.2.0|835ms|892ms|913ms|10KB|Uint8Array|
|[hash.js](https://www.npmjs.com/package/hash.js)|1.1.7|635ms|611ms|1,577ms|7KB|Array|
|[sha.js](https://npmjs.com/package/sha.js)|2.4.11|356ms|965ms|3,512ms|27KB|Buffer|
|[create-hash](https://npmjs.com/package/create-hash)|1.2.0|381ms|1,002ms|3,502ms|97KB|Buffer|
|[jshashes](https://npmjs.com/package/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](https://ja.wikipedia.org/wiki/SHA-1) は、160ビット（16進数40桁）のハッシュ値です。
例：`da39a3ee5e6b4b0d3255bfef95601890afd80709`

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

```js
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](https://nodejs.org/api/crypto.html)|-|70ms 👍|-|-|-|OpenSSL|
|[sha1-uint8array](http://github.com/kawanet/sha1-uint8array)|0.9.0|218ms|346ms 👍|192ms 👍|2KB 👍|Uint8Array|
|[hash.js](https://www.npmjs.com/package/hash.js)|1.1.7|513ms|573ms|908ms|7KB|Array|
|[jssha](https://npmjs.com/package/jssha)|3.2.0|690ms|782ms|770ms|9KB|Uint8Array|
|[crypto-js](https://npmjs.com/package/crypto-js)|4.0.0|779ms|829ms|961ms|108KB|Uint8Array|
|[jshashes](https://npmjs.com/package/jshashes)|1.0.8|686ms|1,448ms|727ms|23KB|Array|
|[tiny-sha1](https://npmjs.com/package/tiny-sha1)|0.2.1|209ms|775ms|3,573ms|2KB|Uint8Array|
|[sha.js](https://npmjs.com/package/sha.js)|2.4.11|360ms|930ms|3,534ms|26KB|Buffer|
|[create-hash](https://npmjs.com/package/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](https://nodejs.org/api/crypto.html) モジュールを使ったほうが３倍くらい速いです。
内部で使われる OpenSSL の実装がカリカリで速すぎるみたい。

~~なお、ブラウザでは、[TextEncoder](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) と [crypto.subtle.digest()](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/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 を使うのが良さそう。