Node.jsで、AES暗号化・復号化する機会があったので、そのメモです。
暗号化アルゴリズム「AES-256-CBC」で行いました。
ざっくりAES CBC暗号化について
AES CBCは、任意の長さのバイナリデータを、鍵を使って暗号化し、同じ鍵を使って復号化する暗号化アルゴリズムです。
しかし、鍵だけで暗号化すると、元データが同じなら、暗号化したデータも毎回同じになって解読されやすくなってしまいます。
なので、AES CBCでは、鍵のみではなく「鍵+暗号化毎に設定する任意の値」を使って暗号化を行い、その「鍵+暗号化毎に設定する任意の値」を使って復号化を行うことにより、同じデータでも毎回暗号化結果が変わるようにして解読されにくくしています。
そして、その「暗号化毎に設定する任意の値」をIV(初期化ベクトル)と呼びます。
鍵について
鍵は暗号化側と復号化側で事前に共有しておく必要があり、鍵は外部に漏らしてはいけません。
鍵の中身は、AES128なら128bit(16byte)、AES256なら256bit(32byte)の任意の値になります。
IVについて
IVは鍵とは違い、外部に漏れても問題ありません。
IVのサイズは暗号化のブロックサイズと等しく、AESのブロックサイズは128bitなので、IVは128bit(16byte)の任意の値になります。
IVの値をどう生成し、どう暗号化側から復号化側に渡すかの仕様は決まっておらず、実装に任されています。
IVを暗号化時にランダム値で生成し、暗号化したデータの先頭にIVを付与して復号化側に渡すケースがよく見られます。 (今回もそうしました)
暗号化データの受け渡し
暗号化したデータはバイナリデータで、それをどう復号化側に伝えるかの仕様は決まっておらず、実装に任されています。
バイナリではなく、文字列の方が扱いやすいので、暗号化したデータをBase64で文字列化して復号化側に送り、 復号化側ではその文字列をバイナリデータに戻して復号化するケースがよく見られます。 (今回もそうしました)
Node.js実装例
以上を踏まえた、Node.jsでの実装例になります。
import * as crypto from 'crypto' const ALGORITHM = 'aes-256-cbc'; const KEY = Buffer.from([ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff ]); // 全ビット1の256bit値を鍵とする function encodeBase64(data: string): string { // 16byteのランダム値を生成してIVとする const iv = crypto.randomBytes(16); // 暗号器作成 const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv); // dataをバイナリにして暗号化 const encData = cipher.update(Buffer.from(data)); // 末端処理 & 先頭にivを付与し、バイナリをbase64(文字列)にして返す return Buffer.concat([iv, encData, cipher.final()]).toString('base64'); } function decodeBase64(data: string): string { // 受け取った暗号化文字列をバイナリに変換 const buff = Buffer.from(data, 'base64'); // iv値である、先頭16byteを取り出す const iv = buff.slice(0, 16); // iv値以降の、暗号化データを取り出す const encData = buff.slice(16); // 復号器作成 const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv); // 暗号化データを復号化 const decData = decipher.update(encData); // 末端処理 & バイナリを文字列に戻す return Buffer.concat([decData, decipher.final()]).toString('utf8'); } (() => { const data = "Hello World!"; console.log(`INPUT : ${data}`); // INPUT : Hello World! const encData = encodeBase64(data); console.log(`ENCODE : ${encData}`); // ENCODE : 0XTIPX06EAClUAFNdT+6EDlv+bOrB6plqkGzd0hEvdU= const decData = decodeBase64(encData); console.log(`DECODE : ${decData}`); // DECODE : Hello World! })();
簡単に説明
- 暗号化
cipher.update(<buffer>)
で、データを暗号化して、暗号化されたデータがバイナリで返ってきます- データが長い場合は
cipher.update(<buffer>)
を繰り返します - 全ての暗号化が終わると、
cipher.final()
を呼び出して、暗号化データの終端を取得します - 一連のデータを連結したものが、最終的な暗号化されたデータとなります
- 復号化
decipher.update(<buffer>)
で、暗号化されたデータを復号し、バイナリで返ってきますdecipher.final()
で、復号化データの終端を取得します- それらのデータを連結したものが、最終的な復号化されたデータになります
鍵生成について
「鍵」というと、一般的にはキーフレーズのようなものを想像してしまいますが、 AESでの鍵は256bit値のことで文字列というわけではありません。
ただ、キーフレーズのような文字列の方が人間には扱いやすいので、 ハッシュ関数を使って、文字列から256bit(32byte)値を生成して鍵とする例が、Node.jsのCryptoのドキュメントには記載されています。
例
const KEY = crypto.scryptSync("キーフレーズ", "", 32);
他には、32個のASCII文字列をキーとする例もありますね。しかしこれだと、ASCII文字に割当られている値に絞られるので、256bitより値が限定的になってしまいます。
例
const KEY = Buffer.from("01234567890123456789012345678912", 'ascii');
感想
最初、鍵が文字列だと思い込んでいてハマりました。
バイナリと文字列が行ったり来たりするので、丁寧に追っていく必要がありますね。
暗号化・復号化の部分に限って言えば、「暗号化前のデータ」「暗号化後のデータ」「鍵」「IV」全てバイナリで、それらを数学的に演算しているに過ぎないので、他の型は混ぜないで、バイナリベースで扱って、一番最後に元の型に戻すと混乱しなくていいかなと思います。