データをパスワードだけで暗号化し、ファイル化して保存することも可能なnpmパッケージを作った話

いい感じのものが無かったので作りました。

[@sounisi5011/encrypted-archive - npm](https://www.npmjs.com/package/@sounisi5011/encrypted-archive)

宣伝も兼ねていますが、どちらかというと暗号技術について詳しい方の意見とか指摘とかが欲しいなという下心が強いです。

## 特徴

* 2021年現在、安全な（と考えられている）暗号アルゴリズムを採用しています。具体的にはAES-GCM 256ビットとChaCha20-Poly1305です。
* パスワードは最新の鍵導出関数[Argon2](https://ja.wikipedia.org/wiki/Argon2)を使って変換しています。
* ファイル化することを考えて、圧縮アルゴリズムにも対応しています。
* 生成されたデータに復号に必要な全ての情報が含まれているので、復号するときはパスワードしか要りません。ファイルに書き込んで保存すれば、[KeePass](https://keepass.info/)のデータベースファイルのように、時間が経った後に他のデバイスで復号することも可能（なはず）。
* **IVを乱数ではなくカウンターで生成します**。
* デカいデータやファイルも暗号化・復号できます（手元にあった47GBのファイルの暗号化と復号に成功しています）

## 用途

* バックアップのためクラウドストレージにアップロードするファイルの暗号化に
* 公開URLにうｐしてアクセスしたいが、第三者には絶対に内容を見られたくない機密データに
* アプリケーション固有のデータファイルに

## なぜ作ったか

Node.js組み込みの[`crypto`モジュール](https://nodejs.org/api/crypto.html)には、暗号化を行うための[`createCipheriv`関数](https://nodejs.org/api/crypto.html#crypto_crypto_createcipheriv_algorithm_key_iv_options)が存在します。しかし、「この関数を使えば簡単に暗号化できるか？」というとそう簡単な事はなく、

* 適切な暗号化アルゴリズムを選ばなければならない
* ちゃんとした`key`（パスワードに相当するが、パスワードをそのまま渡してはいけない）を指定しなければならない
* `iv`（Initialization Vector）を指定しなければならない
* 直感どおりに動かすためには認証タグを暗号化の後に取得し、復号の前に指定しなければならない
* これらの情報を復号するときのために保持しておかなければならない

など、やることが多く、知識なしで扱うのはハードルが高すぎます。

加えて、巨大なデータ（ファイルなど）を変換する場合、Node.jsでは[`Stream`オブジェクト](https://nodejs.org/docs/latest/api/stream.html)を使うことになりますが、これに対応する適切な関数やクラスも用意しなければなりません。

同じような事を考えた先人はいたようで、探したところ以下のパッケージを見つけました。

[string-crypto - npm](https://www.npmjs.com/package/string-crypto)

しかしこのパッケージには、

* [`Stream`オブジェクト](https://nodejs.org/docs/latest/api/stream.html)に対応していない
* [IVを乱数で生成している](https://github.com/jeanlescure/string-crypto/blob/v2.0.1/src/index.ts#L48)
* [鍵導出関数にPBKDF2を使用している](https://github.com/jeanlescure/string-crypto/blob/v2.0.1/src/index.ts#L39)
* 鍵導出関数のパラメーターをクラス作成時に指定しなければ（つまり、別の場所で保持しておかなければ）復号できない

などの不満点がありました。他にもいろいろ探したものの、目的に合うものは無く、結局自作してしまったというわけです。

## 生成されるデータ構造

`@sounisi5011/encrypted-archive`が「archive」の名を持つのには理由があります。文字通り、そのままアーカイブ・ファイルにできるほどの、後方互換性を維持した全部入りの暗号化データを生成するからです。データ構造に関しては以下のドキュメントに図入りで簡単に書いています。

[Structure of the encrypted archive at encrypted-archive-v0.0.2 · sounisi5011/npm-packages](https://github.com/sounisi5011/npm-packages/blob/encrypted-archive-v0.0.2/packages/encrypted-archive/docs/encrypted-archive-structure.md)

ここでその特徴を簡単に箇条書きすると、

* データを複数のチャンクに区切り、それぞれを暗号化することで、巨大なデータもメモリに展開せずに暗号化・復号できる
* 以下の情報を全て含んでいるため、復号時にはパスワードだけが必要
    + 暗号化アルゴリズムの種別
    + IVのバイナリデータ
    + 認証タグのバイナリデータ
    + 鍵導出関数が生成するべき`key`の長さ
    + `key`の生成で用いるsaltのバイナリデータ
    + 鍵導出関数のパラメーター
    + もし行っていれば、圧縮アルゴリズムの種別
* これらのデータを[unsigned varint](https://github.com/multiformats/unsigned-varint)と[Protocol Buffers](https://developers.google.com/protocol-buffers/)を用いてエンコードしているため、将来の拡張にも備え、後方互換性を維持可能（なはず）

これを作った動機の一つは、「静的サイトジェネレーターで短縮URLを生成する時に、一度作ったURLの一覧を暗号化したファイルとしてうｐしたい」というものでした。暗号化の時にどのようなオプションを指定した場合でも、復号の時に必要なものはパスワードだけです。

## 暗号化技術に関する話

書きたいことを先に書いてしまったせいで、前提になる情報がまるで出せていませんでしたね。おそらく詳しい人以外は、頭の中が「なぜIVをランダムに生成してはいけないのか？」「鍵導出関数って何？」「`key`にパスワードを渡しちゃダメなの？」「認証タグってなんだよ」みたいな疑問でいっぱいだと思います。ここから、私が調べて得た知識の範囲で解説します。

### 適切な暗号化アルゴリズムの選択と認証タグについて、および、データを複数のチャンクに区切る理由

私が調べた範囲で得た結論から言えば、以下の2種類の暗号化アルゴリズムのどちらかを使うのが最適です。

* AES-GCM 256bit
* ChaCha20-Poly1305

これ以外にも、AES-CTRなどがありますが、それらは[認証付き暗号（AEAD）](https://ja.wikipedia.org/wiki/%E8%AA%8D%E8%A8%BC%E4%BB%98%E3%81%8D%E6%9A%97%E5%8F%B7)ではありません。

どういうことか？調べ始めた頃の私も勘違いしていたのですが、*「暗号化」と「暗号に使ったパスワードが合っているかどうか」は全く別の機能*です。

例えば、AES-CTRを用いて、あるデータを暗号文に変換したとします。その暗号文は、一見するとランダムなバイナリデータです。これを、誤った`key`で復号しようとした場合、なんと**エラーが発生すること無く成功します**。ただし、「復号」されたデータは、相変わらず意味も読めないバイナリデータです。元の暗号文ではありません。暗号化と復号は数学的な処理の一種であり、ただデータを変換する行為でしかないため、結果が正しいかどうかも、`key`が合致するかどうかも無関係なのです。

「`key`が合っている」かどうかは、暗号文とは別に、認証タグ（MAC、メッセージ認証コード）というものを追加することで行われます。私もあまりよく分かっていないのですが、これを使うことで、「`key`が誤っていた場合に復号が失敗する」直感的な動作になります。また、「暗号文が弄くられた場合にエラーにする」という、大事な機能もあります。

認証タグの生成は別の機能ですが、暗号文の後ろに追加することで、求める暗号化が可能になります。しかし、暗号と認証タグも、合わない種類で組み合わせると脆弱になる場合があります。そういう事を防止するため、安全であることが確認されている暗号アルゴリズムと認証タグがひとまとめになったものが、前述の認証付き暗号です。

なお、認証付き暗号を使用する場合は、生成される認証タグが復号のときにも必要になります[^no-exit-authtag]。よって、認証タグもデータの一部に含めなければなりません。この認証タグの順番が厄介で、[暗号化の時には「暗号文を生成した後」にのみ取得可能](https://nodejs.org/api/crypto.html#crypto_cipher_getauthtag)なのに、[復号の時には「復号の前」に指定](https://nodejs.org/api/crypto.html#crypto_decipher_setauthtag_buffer_encoding)しなければなりません。

[^no-exit-authtag]: 復号の時に認証タグを指定しないと、AES-GCMはエラーになり、ChaCha20-Poly1305は「エラーは発生しないがランダムなデータを「復号」」してしまいます。

つまり、巨大なデータをストリーミング処理で暗号化する場合も、認証タグは復号の時のことを考えて「データの先頭」に追加しなければなりません。しかし認証タグは全データの処理後に取得可能なため、先頭に追加するためには、「暗号化した全てのデータ」を一時保持しておかなければなりません。数十ギガバイトのデータが対象の場合、同じく数十ギガバイトの暗号化済みデータをメモリなりストレージなりに一時的に保存しなければならないわけですが、そんなことなんてやってられません[^maximum-node-buffer-length]。

[^maximum-node-buffer-length]: 加えて、Node.jsの`Buffer`オブジェクトは、[最大でもせいぜい4GB程度まで](https://nodejs.org/api/buffer.html#buffer_buffer_constants_max_length)のデータしか保持できません。

そこで`@sounisi5011/encrypted-archive`では、データを短い「チャンク」に区切って、それぞれを順番に暗号化する方式を採用しています。各チャンクは数十キロバイト〜数百メガバイトなので、このデータなら一時的にメモリに保持できます。そして暗号化した後に、生成された認証タグを先頭に付け加えて出力します。復号の際には、先頭からチャンク毎に認証タグを読み取って復号するだけです。

### `key`にパスワードを渡してはいけない理由と鍵導出関数

調べても分からなかっため、teratailで質問しました。

[セキュリティー - AES-256 GCMに渡すkeyに、パスワードそのものではなく、鍵導出関数（PBKDF2など）で生成したハッシュ値を指定する理由は？｜teratail](https://teratail.com/questions/332738)

その結果得られた解答が非常に納得できたため、`@sounisi5011/encrypted-archive`でも常にパスワードを鍵導出関数で変換するようにしています。

まず、なぜパスワードを渡してはいけないのか？簡単に言うと、パスワードは乱数よりも強度が弱いからです。人間が打つパスワードは、覚えやすいようある程度規則性があったり、短かったりします。このため、

* よくありがちなパスワードで総当り計算をすることにより、暗号文を解読できてしまう可能性がある
* 単純に`key`が短くなり、暗号文の強度が下がる

危険性があります。このため、パスワードと乱数（salt）を元に、十分な長さのランダム（に見える）データを生成し、これを`key`に渡す必要があります。

任意の長さのデータを一定の長さのランダム（に見える）データに変換する方法としてよくあるのは[ハッシュ関数](https://ja.wikipedia.org/wiki/%E3%83%8F%E3%83%83%E3%82%B7%E3%83%A5%E9%96%A2%E6%95%B0)ですが、ハッシュ関数は高速に設計されているため、変換したところであまり意味はありません。変換に十分な時間がかかり（ゆえに総当り攻撃に時間がかかり）、かつ安全である変換方式が必要です。この目的で設計されたものが[鍵導出関数](https://ja.wikipedia.org/wiki/%E9%8D%B5%E5%B0%8E%E5%87%BA%E9%96%A2%E6%95%B0)になります。

鍵導出関数の種類には、以下のようなものがあります。

* PBKDF2

    ハッシュ関数を組み合わせて鍵導出関数にしたもの。昔からあって、[Node.js組み込みの`crypto`モジュールにも入っている](https://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2_password_salt_iterations_keylen_digest_callback)。現在も推奨されているものの、他のものと比較すると弱い。[ウィキペディアにもその事が書かれている](https://ja.wikipedia.org/wiki/PBKDF2#PBKDF2%E3%81%AE%E4%BB%A3%E6%9B%BF)。

* bcrypt

    [PHPに入っている](https://www.php.net/manual/ja/function.password-hash.php)ことで有名（だと勝手に思っている）。PBKDF2よりも強いが、**ほとんどの実装が先頭の72バイトしか使わない**。長いパスワードの意味が無くなってしまう。もっと良い選択肢があるのにこれを選ぶのはちょっと…

* scrypt

    bcryptの後に誕生したもので、bcryptよりもいいらしい（要出典）。
    しかし調べると、「暗号通貨界隈でよく使われているせいで、ハードウェアで高速化した実装が存在する」らしいことが判明。

    [パスワードハッシュ：PBKDF2、Scrypt、Bcrypt、ARGON2](https://ichi.pro/pasuwa-do-hasshu-pbkdf-2-scrypt-bcrypt-argon-2-248879115229582)

    あまり選びたくはない…

* AES-KDF

    KeePassのページに載っていたもの。

    [Security - KeePass](https://keepass.info/help/base/security.html#secdictprotect)

    調べても他の情報が見つからず、詳細は不明。

* Argon2

    **最強の選択肢**。最近誕生したもので、パスワードハッシュのコンペで優勝したらしい。以下2つの記事でも推奨されている。

    * [パスワードハッシュ：PBKDF2、Scrypt、Bcrypt、ARGON2](https://ichi.pro/pasuwa-do-hasshu-pbkdf-2-scrypt-bcrypt-argon-2-248879115229582)
    * [Password Storage - OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)

    また、KeePassも対応している。

    [Security - KeePass](https://keepass.info/help/base/security.html)

調べた内容から判断し、~~新しいもの好きな性分による独断と偏見の選択も加味して~~`@sounisi5011/encrypted-archive`ではArgon2のみを採用するという思い切ったことをしています。Argon2はNode.jsの組み込みモジュールでは使えないので、[`argon2-browser`パッケージ]を使用しています[^why-not-use-native-argon2]。

[`argon2-browser`パッケージ]: https://www.npmjs.com/package/argon2-browser

[^why-not-use-native-argon2]: なぜ、C言語で書かれた高速な[`argon2`パッケージ](https://www.npmjs.com/package/argon2)ではなく、WASMで作られた[`argon2-browser`パッケージ]を採用したかというと、`argon2`パッケージは対応していない環境でビルドが走るからです。「マイナーなOSを搭載し、`make`コマンドすら同梱されていない」NASのような環境では、ビルドが必要な`argon2`パッケージは使えません。実際、ビルドが失敗しました。そこで、WASM製の`argon2-browser`パッケージを採用しています。

### なぜIVを乱数で生成してはいけないのか

以下の記事にまとまっていますが、簡単に言うと、**セキュリティの脆弱性につながるからです**。

[本当は怖いAES-GCMの話 - ぼちぼち日記](https://jovi0608.hatenablog.com/entry/20160524/1464054882)

同じ`key`で暗号化する際に、同じ値のIVを再利用してしまった場合、暗号化されたデータに、何か色々と数学的な処理を行うことで、改ざんが容易になってしまいます。前述した認証タグも「改ざん後の内容に合うもの」を生成できてしまうため、暗号文が正しい内容なのか、それとも改ざんされたものなのか、復号時に判定できません。

ここで、もしIVの生成に乱数を使用してしまった場合、同じ値のIVが偶然生成されてしまう可能性が生じます。低いように思われますが、[誕生日のパラドックス](https://ja.wikipedia.org/wiki/%E8%AA%95%E7%94%9F%E6%97%A5%E3%81%AE%E3%83%91%E3%83%A9%E3%83%89%E3%83%83%E3%82%AF%E3%82%B9)が例に出されているように、無視できるほど低い可能性というわけではありません。

私の理解では、可能な事はあくまで改ざんであり、暗号文の解読はできないっぽいです（間違ってたらすまぬ）。しかしいずれにせよ、正しい暗号文かどうかの判定ができなくなるため、この問題への対応は必要だと考えました。そのため`@sounisi5011/encrypted-archive`では、IVをカウンターで生成しています。具体的な実装は以下になります：

[`packages/encrypted-archive/src/nonce.ts` at encrypted-archive-v0.0.2 · sounisi5011/npm-packages](https://github.com/sounisi5011/npm-packages/blob/encrypted-archive-v0.0.2/packages/encrypted-archive/src/nonce.ts)

JavaScript(ECMAScript)が生成するミリ秒単位のUnix時間と、0からカウントアップするデータを連結してします。[JavaScriptのUnix時間は最大8,640,000,000,000,000ミリ秒](https://262.ecma-international.org/10.0/#sec-time-values-and-time-range)で、これは「西暦27万5760年9月13日 午前0時0分0秒(UTC)」を意味するため、このケタがあふれることはおそらく無いはずです。

また、この後ろに、5バイトのカウンター（現在サポートしている暗号化アルゴリズムはどちらも12バイトのIVが必要なため）が続きます。組み合わせの数は2^40-1で、これは1TiB（テビバイト）のデータ長と同じくらいの膨大な数です。各チャンクの入力データの長さが1バイトだったとしても、同じ`key`で1TiBものデータを暗号化しない限りは、カウントが振り切れることはありません[^not-same-key][^if-iv-counter-overflow]。

同じ`key`で小さなデータを大量に暗号化するような使い方（たとえば、IoT機器の通信データの暗号化など）をしたりしなければ、このケタがあふれることも無いはずですし、その前にUnix時間が変化してしまうでしょう。

[^not-same-key]: 加えて言えば、同じパスワードを指定した場合でも、`key`は暗号化の度に異なるsaltを元に鍵導出関数で生成されているため、暗号化のたびに全く異なります。[saltは乱数を使って生成](https://github.com/sounisi5011/npm-packages/blob/encrypted-archive-v0.0.2/packages/encrypted-archive/src/encrypt.ts#L42)されていますが、暗号化の度に1度だけ生成されているため、これが重複する可能性は非常に低い（はず）です。

[^if-iv-counter-overflow]: 実は、[IV生成のカウンターが振り切れた場合は、Unix時間のほうをインクリメントする](https://github.com/sounisi5011/npm-packages/blob/encrypted-archive-v0.0.2/packages/encrypted-archive/src/nonce.ts#L138)ように作ってあります。Unix時間はミリ秒単位なので、通常は暗号化の途中で変動しているはずで、1つ増えたところで大した影響はありません。

## まだやり残したこと

「セキュリティに関わるから、絶対に安全に動くように」とこだわって作ったところ、なんと完成まで一ヶ月もかかってしまった`@sounisi5011/encrypted-archive`ですが、まだやり残した事は残っています。

* 簡単に使えるCLI

    後々、「`@sounisi5011/encrypted-archive-cli`」なんて名前の別のnpmパッケージでも作って公開したいですネ。今のままでは、ちょっとファイルを変換したい時でもコードを書かなければならないので。

* 暗号化・復号の解析処理に対応

    例えばCLIを使う時に、暗号化や復号の進捗表示をしたり、「読み取ったメタデータ」「現在nチャンクを処理中」「現在のIV」などを出したいわけです。またデバッグやテストのために、暗号化や復号の途中でデータを読んだり書き換えたりしたくもなります。しかし現状、そういった細かい解析処理のために使えるものは一切提供されていません。CLIの作成に合わせて追加したいです。

* `key`を途中のチャンクで再生成する

    現在の実装では、[`key`は暗号化の前に1度だけ生成](https://github.com/sounisi5011/npm-packages/blob/encrypted-archive-v0.0.2/packages/encrypted-archive/src/encrypt.ts#L157-L164)されます。この`key`は暗号文全体で使われますが、途中で`key`を変更したほうが、暗号文の強度は上がるはずです（要出典）。

    たとえ、攻撃者が`key`を1つ割り出したとしても、途中から別の`key`が使われていれば、暗号文全体が復号されてしまう可能性は低くなります。

    もし、暗号強度が低下するようなデメリットが無ければ、途中のチャンクに新しい`key`の生成データを含め、以降のチャンクで新しい`key`を使用するのも良いかもしれません。

* 専用の継承クラスの`Error`オブジェクトを投げる

    エラーが発生した場合、`throw`演算子で[`Error`オブジェクト](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Error)を投げるのがJavaScriptのやり方です。この`Error`オブジェクトを、ちゃんと継承した、細分化されたクラスにしたいです。[`instanceof`演算子](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/instanceof)で`Error`オブジェクトの種別を判定し、条件分岐しやすくしたいです。あと、もっと読みやすいエラーメッセージにもしたい。