#Overview
Node.jsはJavaScriptで書けるから、Webの中では"Write once, run anywhere"的な美味しいこともある。
しかし、各環境にbuiltinされているAPIを使ったときはそうはいかない時がある。
今回は暗号化のCryptoで不覚にも1日ハマったのでその記録を残しておく。
#Target reader
- Node.jsで暗号化したデータをブラウザで復号化したいと思っている方。
#Prerequisite
- AESの概要は理解していること。
- 今回はAES256-CBCを使用する。
- 記憶が正しければAES192はブラウザのAPIでサポートされていない旨のエラーが出たため。
#Body
##どうして片方のAPIで統一しないの?
これはいい質問だ。実際のところ、Node.jsのcryptoをブラウザで実行したことがある。
どうして採用されなかったのか?なぜなら100KBほどバンドルサイズが増えたから。
詳しく知りたい場合は、この方の記事を読んでみるといいかもしれない。
https://engineering.mixmax.com/blog/requiring-node-builtins-with-webpack
一言でいうと、以下のブラウザ用cryptoがバンドルされてしまったため。
https://github.com/crypto-browserify/crypto-browserify
ブラウザのAPIを使えば100KBのバンドルを回避できるのだから、別々のAPIを使用するのは当然といってもいい。
もしかしたら差分を吸収するI/Fのパッケージがあるかもしれないが調べてない
##Node.jsのCrypto
基本的には公式ドキュメントのコードがそのまま使用できる。
https://nodejs.org/api/crypto.html#crypto_class_cipher
大して見どころはないが、私のソースも載せておく。
ライブラリの方のソース。
import crypto from 'crypto';
function createCipheriv(algorithm, key, iv) {
console.log("crypt.key:", key);
console.log("crypt.iv:", iv);
const cipher = crypto.createCipheriv(algorithm, key, iv);
return cipher;
}
function createDecipheriv(algorithm, key, iv) {
console.log("decrypt.key:", key);
console.log("decrypt.iv:", iv);
const decipher = crypto.createDecipheriv(algorithm, key, iv);
return decipher;
}
async function cryptByNodeApi(cipher, plainText) {
console.log('平文: ' + plainText);
let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
console.log('暗号化:', encrypted);
return encrypted;
}
async function decryptByNodeApi(decipher, encrypted) {
// 復号
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
console.log('復号化: ', decrypted);
return decrypted;
}
export {
createCipheriv, createDecipheriv,
cryptByNodeApi, decryptByNodeApi
}
実行部分のソースの抜粋。
import { cryptByNodeApi, decryptByNodeApi, createCipheriv, createDecipheriv } from './libs/nodeCrypto';
export default function App() {
async function handleClickNodeToBrowser() {
const algorithm = 'aes-256-cbc';
const key = crypto.randomBytes(32);
const iv = Buffer.alloc(16, 0);
// NodeのCryptoAPIで暗号化
const cipher = createCipheriv(algorithm, key, iv);
const encrypted = Buffer.from(await cryptByNodeApi(cipher, plainText), "hex").buffer;
// Nodeのcipherに該当するものを作る
const keyForbrowser = await importKeyByBrowserApi(key);
// ブラウザのCryptoAPIで復号化
await decryptByBrowserApi(encrypted, keyForbrowser, iv);
}
};
注意点として以下のことがあげられる。
- 公式ドキュメントとは異なりAESの256bit(32Byte)なのでキーは32Byteになる。
- IVは16Byte固定。
- ソースでは0固定にしているが本来は値を与えること。
- cryptByNodeApi()ではhexにしているため、ブラウザAPIへの入力に合わせるためArrayBufferを取り出している。
ブラウザAPIの方はArrayBufferを与えないとエラーになるが、実際何がArrayBufferでなくてはいけないのかわからなくてハマった
SubtleCrypto.decrypt()のドキュメントを見るとBufferSourceとなっており、リンク先に行かないと気が付かない罠。
data is a BufferSource containing the data to be decrypted (also known as ciphertext).
##BrowserのCrypto
基本的には公式ドキュメント先のコードがそのまま使用できる。
https://github.com/mdn/dom-examples/blob/master/web-crypto/encrypt-decrypt/aes-cbc.js
大して見どころはないが、私のソースも載せておく。
ライブラリの方のソース。
async function cryptByBrowserApi(plainText, key, iv) {
console.log('平文: ' + plainText);
console.log("crypt.key:", key);
console.log("crypt.iv:", iv);
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-CBC",
iv
},
key,
new TextEncoder().encode(plainText)
);
console.log('暗号化:', encrypted);
console.log('暗号化:', Buffer.from(encrypted).toString('hex'));
return encrypted;
}
async function decryptByBrowserApi(encrypted, key, iv) {
console.log("decrypt.encrypted:", encrypted);
console.log("decrypt.key:", key);
console.log("decrypt.iv:", iv);
const decrypted = await window.crypto.subtle.decrypt(
{
name: "AES-CBC",
iv,
},
key,
encrypted
);
const plainText = new TextDecoder().decode(decrypted);
console.log('復号化:', plainText);
return plainText;
}
async function importKeyByBrowserApi(rawKey) {
const key = await window.crypto.subtle.importKey(
"raw",
rawKey,
"AES-CBC",
true,
["encrypt", "decrypt"]
);
return key;
}
async function generateKeyByBrowserApi() {
const key = window.crypto.subtle.generateKey(
{
name: "AES-CBC",
length: 256
},
true,
["encrypt", "decrypt"]
);
return key;
}
export {
cryptByBrowserApi, decryptByBrowserApi, generateKeyByBrowserApi, importKeyByBrowserApi
}
注目ポイントは、importKey()とdecrypt()の二つを使用しないといけないところ。
importKey()であっているのだろうか?rawKeyは正しく指定しているのか?ArrayBufferじゃないといけないのエラーって何?
複数の誤りでエラーポイントが特定できず完成までに1日も消耗してしまった。
rawの中身については公式ドキュメントのソースの1行目に具体的にある。
https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#Raw
const rawKey = window.crypto.getRandomValues(new Uint8Array(16));
しかし、次の行でfunction importSecretKey(rawKey) {
ともなっており、rawKeyは引数しかないと思ってしまった。
Uint8Array(16)ってちゃんとあるのに
ブラウザで暗号化する場合、key指定不要のgenerateKey()を利用するため、Node.jsのkeyを使えるのかもその時はわかっていなかった。
加えて生成されるCryptoKeyの中身が見れないのが、問題解決を遅らせた。
CryptoKeyがおかしいのか、decrypt()がおかしいのか見当がつかなかった。
これを間違わなければ1時間もあれば終わるようなもの。。。
#Conclusion
JavaScriptは型を宣言しないとはいえ、builtinAPIはTypeScriptの型が見みれる。(複数の入力があるためどれがどれに対応するかはわからないが)
それにもかかわらず何とかなるだろうと、詳しく見ずにリトライを繰り返したのがよくなかった。
丁寧に見ていけば大丈夫…なはず。Node.jsは怖くない
Have a great day!
#Appendices
今回のコードをブラウザで動かせるようにしたソースコード。
自分用なので少し不親切なのに注意。
ブラウザで動作確認(Node.APIはbrowserifyが使用される)
npm start
純粋なNode.APIでの確認
node -r esm ./src/cli.js