いい感じのものが無かったので作りました。
@sounisi5011/encrypted-archive - npm
宣伝も兼ねていますが、どちらかというと暗号技術について詳しい方の意見とか指摘とかが欲しいなという下心が強いです。
特徴
- 2021年現在、安全な(と考えられている)暗号アルゴリズムを採用しています。具体的にはAES-GCM 256ビットとChaCha20-Poly1305です。
- パスワードは最新の鍵導出関数Argon2を使って変換しています。
- ファイル化することを考えて、圧縮アルゴリズムにも対応しています。
- 生成されたデータに復号に必要な全ての情報が含まれているので、復号するときはパスワードしか要りません。ファイルに書き込んで保存すれば、KeePassのデータベースファイルのように、時間が経った後に他のデバイスで復号することも可能(なはず)。
- IVを乱数ではなくカウンターで生成します。
- デカいデータやファイルも暗号化・復号できます(手元にあった47GBのファイルの暗号化と復号に成功しています)
用途
- バックアップのためクラウドストレージにアップロードするファイルの暗号化に
- 公開URLにうpしてアクセスしたいが、第三者には絶対に内容を見られたくない機密データに
- アプリケーション固有のデータファイルに
なぜ作ったか
Node.js組み込みのcrypto
モジュールには、暗号化を行うためのcreateCipheriv
関数が存在します。しかし、「この関数を使えば簡単に暗号化できるか?」というとそう簡単な事はなく、
- 適切な暗号化アルゴリズムを選ばなければならない
- ちゃんとした
key
(パスワードに相当するが、パスワードをそのまま渡してはいけない)を指定しなければならない -
iv
(Initialization Vector)を指定しなければならない - 直感どおりに動かすためには認証タグを暗号化の後に取得し、復号の前に指定しなければならない
- これらの情報を復号するときのために保持しておかなければならない
など、やることが多く、知識なしで扱うのはハードルが高すぎます。
加えて、巨大なデータ(ファイルなど)を変換する場合、Node.jsではStream
オブジェクトを使うことになりますが、これに対応する適切な関数やクラスも用意しなければなりません。
同じような事を考えた先人はいたようで、探したところ以下のパッケージを見つけました。
しかしこのパッケージには、
-
Stream
オブジェクトに対応していない - IVを乱数で生成している
- 鍵導出関数にPBKDF2を使用している
- 鍵導出関数のパラメーターをクラス作成時に指定しなければ(つまり、別の場所で保持しておかなければ)復号できない
などの不満点がありました。他にもいろいろ探したものの、目的に合うものは無く、結局自作してしまったというわけです。
生成されるデータ構造
@sounisi5011/encrypted-archive
が「archive」の名を持つのには理由があります。文字通り、そのままアーカイブ・ファイルにできるほどの、後方互換性を維持した全部入りの暗号化データを生成するからです。データ構造に関しては以下のドキュメントに図入りで簡単に書いています。
Structure of the encrypted archive at encrypted-archive-v0.0.2 · sounisi5011/npm-packages
ここでその特徴を簡単に箇条書きすると、
- データを複数のチャンクに区切り、それぞれを暗号化することで、巨大なデータもメモリに展開せずに暗号化・復号できる
- 以下の情報を全て含んでいるため、復号時にはパスワードだけが必要
- 暗号化アルゴリズムの種別
- IVのバイナリデータ
- 認証タグのバイナリデータ
- 鍵導出関数が生成するべき
key
の長さ -
key
の生成で用いるsaltのバイナリデータ - 鍵導出関数のパラメーター
- もし行っていれば、圧縮アルゴリズムの種別
- これらのデータをunsigned varintとProtocol Buffersを用いてエンコードしているため、将来の拡張にも備え、後方互換性を維持可能(なはず)
これを作った動機の一つは、「静的サイトジェネレーターで短縮URLを生成する時に、一度作ったURLの一覧を暗号化したファイルとしてうpしたい」というものでした。暗号化の時にどのようなオプションを指定した場合でも、復号の時に必要なものはパスワードだけです。
暗号化技術に関する話
書きたいことを先に書いてしまったせいで、前提になる情報がまるで出せていませんでしたね。おそらく詳しい人以外は、頭の中が「なぜIVをランダムに生成してはいけないのか?」「鍵導出関数って何?」「key
にパスワードを渡しちゃダメなの?」「認証タグってなんだよ」みたいな疑問でいっぱいだと思います。ここから、私が調べて得た知識の範囲で解説します。
適切な暗号化アルゴリズムの選択と認証タグについて、および、データを複数のチャンクに区切る理由
私が調べた範囲で得た結論から言えば、以下の2種類の暗号化アルゴリズムのどちらかを使うのが最適です。
- AES-GCM 256bit
- ChaCha20-Poly1305
これ以外にも、AES-CTRなどがありますが、それらは認証付き暗号(AEAD)ではありません。
どういうことか?調べ始めた頃の私も勘違いしていたのですが、「暗号化」と「暗号に使ったパスワードが合っているかどうか」は全く別の機能です。
例えば、AES-CTRを用いて、あるデータを暗号文に変換したとします。その暗号文は、一見するとランダムなバイナリデータです。これを、誤ったkey
で復号しようとした場合、なんとエラーが発生すること無く成功します。ただし、「復号」されたデータは、相変わらず意味も読めないバイナリデータです。元の暗号文ではありません。暗号化と復号は数学的な処理の一種であり、ただデータを変換する行為でしかないため、結果が正しいかどうかも、key
が合致するかどうかも無関係なのです。
「key
が合っている」かどうかは、暗号文とは別に、認証タグ(MAC、メッセージ認証コード)というものを追加することで行われます。私もあまりよく分かっていないのですが、これを使うことで、「key
が誤っていた場合に復号が失敗する」直感的な動作になります。また、「暗号文が弄くられた場合にエラーにする」という、大事な機能もあります。
認証タグの生成は別の機能ですが、暗号文の後ろに追加することで、求める暗号化が可能になります。しかし、暗号と認証タグも、合わない種類で組み合わせると脆弱になる場合があります。そういう事を防止するため、安全であることが確認されている暗号アルゴリズムと認証タグがひとまとめになったものが、前述の認証付き暗号です。
なお、認証付き暗号を使用する場合は、生成される認証タグが復号のときにも必要になります1。よって、認証タグもデータの一部に含めなければなりません。この認証タグの順番が厄介で、暗号化の時には「暗号文を生成した後」にのみ取得可能なのに、復号の時には「復号の前」に指定しなければなりません。
つまり、巨大なデータをストリーミング処理で暗号化する場合も、認証タグは復号の時のことを考えて「データの先頭」に追加しなければなりません。しかし認証タグは全データの処理後に取得可能なため、先頭に追加するためには、「暗号化した全てのデータ」を一時保持しておかなければなりません。数十ギガバイトのデータが対象の場合、同じく数十ギガバイトの暗号化済みデータをメモリなりストレージなりに一時的に保存しなければならないわけですが、そんなことなんてやってられません2。
そこで@sounisi5011/encrypted-archive
では、データを短い「チャンク」に区切って、それぞれを順番に暗号化する方式を採用しています。各チャンクは数十キロバイト〜数百メガバイトなので、このデータなら一時的にメモリに保持できます。そして暗号化した後に、生成された認証タグを先頭に付け加えて出力します。復号の際には、先頭からチャンク毎に認証タグを読み取って復号するだけです。
key
にパスワードを渡してはいけない理由と鍵導出関数
調べても分からなかっため、teratailで質問しました。
セキュリティー - AES-256 GCMに渡すkeyに、パスワードそのものではなく、鍵導出関数(PBKDF2など)で生成したハッシュ値を指定する理由は?|teratail
その結果得られた解答が非常に納得できたため、@sounisi5011/encrypted-archive
でも常にパスワードを鍵導出関数で変換するようにしています。
まず、なぜパスワードを渡してはいけないのか?簡単に言うと、パスワードは乱数よりも強度が弱いからです。人間が打つパスワードは、覚えやすいようある程度規則性があったり、短かったりします。このため、
- よくありがちなパスワードで総当り計算をすることにより、暗号文を解読できてしまう可能性がある
- 単純に
key
が短くなり、暗号文の強度が下がる
危険性があります。このため、パスワードと乱数(salt)を元に、十分な長さのランダム(に見える)データを生成し、これをkey
に渡す必要があります。
任意の長さのデータを一定の長さのランダム(に見える)データに変換する方法としてよくあるのはハッシュ関数ですが、ハッシュ関数は高速に設計されているため、変換したところであまり意味はありません。変換に十分な時間がかかり(ゆえに総当り攻撃に時間がかかり)、かつ安全である変換方式が必要です。この目的で設計されたものが鍵導出関数になります。
鍵導出関数の種類には、以下のようなものがあります。
-
PBKDF2
ハッシュ関数を組み合わせて鍵導出関数にしたもの。昔からあって、Node.js組み込みの
crypto
モジュールにも入っている。現在も推奨されているものの、他のものと比較すると弱い。ウィキペディアにもその事が書かれている。 -
bcrypt
PHPに入っていることで有名(だと勝手に思っている)。PBKDF2よりも強いが、ほとんどの実装が先頭の72バイトしか使わない。長いパスワードの意味が無くなってしまう。もっと良い選択肢があるのにこれを選ぶのはちょっと…
-
scrypt
bcryptの後に誕生したもので、bcryptよりもいいらしい(要出典)。
しかし調べると、「暗号通貨界隈でよく使われているせいで、ハードウェアで高速化した実装が存在する」らしいことが判明。パスワードハッシュ:PBKDF2、Scrypt、Bcrypt、ARGON2
あまり選びたくはない…
-
AES-KDF
KeePassのページに載っていたもの。
調べても他の情報が見つからず、詳細は不明。
-
Argon2
最強の選択肢。最近誕生したもので、パスワードハッシュのコンペで優勝したらしい。以下2つの記事でも推奨されている。
また、KeePassも対応している。
調べた内容から判断し、新しいもの好きな性分による独断と偏見の選択も加味して@sounisi5011/encrypted-archive
ではArgon2のみを採用するという思い切ったことをしています。Argon2はNode.jsの組み込みモジュールでは使えないので、argon2-browser
パッケージを使用しています3。
なぜIVを乱数で生成してはいけないのか
以下の記事にまとまっていますが、簡単に言うと、セキュリティの脆弱性につながるからです。
同じkey
で暗号化する際に、同じ値のIVを再利用してしまった場合、暗号化されたデータに、何か色々と数学的な処理を行うことで、改ざんが容易になってしまいます。前述した認証タグも「改ざん後の内容に合うもの」を生成できてしまうため、暗号文が正しい内容なのか、それとも改ざんされたものなのか、復号時に判定できません。
ここで、もしIVの生成に乱数を使用してしまった場合、同じ値のIVが偶然生成されてしまう可能性が生じます。低いように思われますが、誕生日のパラドックスが例に出されているように、無視できるほど低い可能性というわけではありません。
私の理解では、可能な事はあくまで改ざんであり、暗号文の解読はできないっぽいです(間違ってたらすまぬ)。しかしいずれにせよ、正しい暗号文かどうかの判定ができなくなるため、この問題への対応は必要だと考えました。そのため@sounisi5011/encrypted-archive
では、IVをカウンターで生成しています。具体的な実装は以下になります:
packages/encrypted-archive/src/nonce.ts
at encrypted-archive-v0.0.2 · sounisi5011/npm-packages
JavaScript(ECMAScript)が生成するミリ秒単位のUnix時間と、0からカウントアップするデータを連結してします。JavaScriptのUnix時間は最大8,640,000,000,000,000ミリ秒で、これは「西暦27万5760年9月13日 午前0時0分0秒(UTC)」を意味するため、このケタがあふれることはおそらく無いはずです。
また、この後ろに、5バイトのカウンター(現在サポートしている暗号化アルゴリズムはどちらも12バイトのIVが必要なため)が続きます。組み合わせの数は2^40-1で、これは1TiB(テビバイト)のデータ長と同じくらいの膨大な数です。各チャンクの入力データの長さが1バイトだったとしても、同じkey
で1TiBものデータを暗号化しない限りは、カウントが振り切れることはありません45。
同じkey
で小さなデータを大量に暗号化するような使い方(たとえば、IoT機器の通信データの暗号化など)をしたりしなければ、このケタがあふれることも無いはずですし、その前にUnix時間が変化してしまうでしょう。
まだやり残したこと
「セキュリティに関わるから、絶対に安全に動くように」とこだわって作ったところ、なんと完成まで一ヶ月もかかってしまった@sounisi5011/encrypted-archive
ですが、まだやり残した事は残っています。
-
簡単に使えるCLI
後々、「
@sounisi5011/encrypted-archive-cli
」なんて名前の別のnpmパッケージでも作って公開したいですネ。今のままでは、ちょっとファイルを変換したい時でもコードを書かなければならないので。 -
暗号化・復号の解析処理に対応
例えばCLIを使う時に、暗号化や復号の進捗表示をしたり、「読み取ったメタデータ」「現在nチャンクを処理中」「現在のIV」などを出したいわけです。またデバッグやテストのために、暗号化や復号の途中でデータを読んだり書き換えたりしたくもなります。しかし現状、そういった細かい解析処理のために使えるものは一切提供されていません。CLIの作成に合わせて追加したいです。
-
key
を途中のチャンクで再生成する現在の実装では、
key
は暗号化の前に1度だけ生成されます。このkey
は暗号文全体で使われますが、途中でkey
を変更したほうが、暗号文の強度は上がるはずです(要出典)。たとえ、攻撃者が
key
を1つ割り出したとしても、途中から別のkey
が使われていれば、暗号文全体が復号されてしまう可能性は低くなります。もし、暗号強度が低下するようなデメリットが無ければ、途中のチャンクに新しい
key
の生成データを含め、以降のチャンクで新しいkey
を使用するのも良いかもしれません。 -
専用の継承クラスの
Error
オブジェクトを投げるエラーが発生した場合、
throw
演算子でError
オブジェクトを投げるのがJavaScriptのやり方です。このError
オブジェクトを、ちゃんと継承した、細分化されたクラスにしたいです。instanceof
演算子でError
オブジェクトの種別を判定し、条件分岐しやすくしたいです。あと、もっと読みやすいエラーメッセージにもしたい。
-
復号の時に認証タグを指定しないと、AES-GCMはエラーになり、ChaCha20-Poly1305は「エラーは発生しないがランダムなデータを「復号」」してしまいます。 ↩
-
加えて、Node.jsの
Buffer
オブジェクトは、最大でもせいぜい4GB程度までのデータしか保持できません。 ↩ -
なぜ、C言語で書かれた高速な
argon2
パッケージではなく、WASMで作られたargon2-browser
パッケージを採用したかというと、argon2
パッケージは対応していない環境でビルドが走るからです。「マイナーなOSを搭載し、make
コマンドすら同梱されていない」NASのような環境では、ビルドが必要なargon2
パッケージは使えません。実際、ビルドが失敗しました。そこで、WASM製のargon2-browser
パッケージを採用しています。 ↩ -
加えて言えば、同じパスワードを指定した場合でも、
key
は暗号化の度に異なるsaltを元に鍵導出関数で生成されているため、暗号化のたびに全く異なります。saltは乱数を使って生成されていますが、暗号化の度に1度だけ生成されているため、これが重複する可能性は非常に低い(はず)です。 ↩ -
実は、IV生成のカウンターが振り切れた場合は、Unix時間のほうをインクリメントするように作ってあります。Unix時間はミリ秒単位なので、通常は暗号化の途中で変動しているはずで、1つ増えたところで大した影響はありません。 ↩