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
大して見どころはないが、私のソースも載せておく。
ライブラリの方のソース。
importcryptofrom'crypto';functioncreateCipheriv(algorithm,key,iv){console.log("crypt.key:",key);console.log("crypt.iv:",iv);constcipher=crypto.createCipheriv(algorithm,key,iv);returncipher;}functioncreateDecipheriv(algorithm,key,iv){console.log("decrypt.key:",key);console.log("decrypt.iv:",iv);constdecipher=crypto.createDecipheriv(algorithm,key,iv);returndecipher;}asyncfunctioncryptByNodeApi(cipher,plainText){console.log('平文: '+plainText);letencrypted=cipher.update(plainText,'utf8','hex');encrypted+=cipher.final('hex');console.log('暗号化:',encrypted);returnencrypted;}asyncfunctiondecryptByNodeApi(decipher,encrypted){// 復号letdecrypted=decipher.update(encrypted,'hex','utf8');decrypted+=decipher.final('utf8');console.log('復号化: ',decrypted);returndecrypted;}export{createCipheriv,createDecipheriv,cryptByNodeApi,decryptByNodeApi}
実行部分のソースの抜粋。
import{cryptByNodeApi,decryptByNodeApi,createCipheriv,createDecipheriv}from'./libs/nodeCrypto';exportdefaultfunctionApp(){asyncfunctionhandleClickNodeToBrowser(){constalgorithm='aes-256-cbc';constkey=crypto.randomBytes(32);constiv=Buffer.alloc(16,0);// NodeのCryptoAPIで暗号化constcipher=createCipheriv(algorithm,key,iv);constencrypted=Buffer.from(awaitcryptByNodeApi(cipher,plainText),"hex").buffer;// Nodeのcipherに該当するものを作るconstkeyForbrowser=awaitimportKeyByBrowserApi(key);// ブラウザのCryptoAPIで復号化awaitdecryptByBrowserApi(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
大して見どころはないが、私のソースも載せておく。
ライブラリの方のソース。
asyncfunctioncryptByBrowserApi(plainText,key,iv){console.log('平文: '+plainText);console.log("crypt.key:",key);console.log("crypt.iv:",iv);constencrypted=awaitwindow.crypto.subtle.encrypt({name:"AES-CBC",iv},key,newTextEncoder().encode(plainText));console.log('暗号化:',encrypted);console.log('暗号化:',Buffer.from(encrypted).toString('hex'));returnencrypted;}asyncfunctiondecryptByBrowserApi(encrypted,key,iv){console.log("decrypt.encrypted:",encrypted);console.log("decrypt.key:",key);console.log("decrypt.iv:",iv);constdecrypted=awaitwindow.crypto.subtle.decrypt({name:"AES-CBC",iv,},key,encrypted);constplainText=newTextDecoder().decode(decrypted);console.log('復号化:',plainText);returnplainText;}asyncfunctionimportKeyByBrowserApi(rawKey){constkey=awaitwindow.crypto.subtle.importKey("raw",rawKey,"AES-CBC",true,["encrypt","decrypt"]);returnkey;}asyncfunctiongenerateKeyByBrowserApi(){constkey=window.crypto.subtle.generateKey({name:"AES-CBC",length:256},true,["encrypt","decrypt"]);returnkey;}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
constrawKey=window.crypto.getRandomValues(newUint8Array(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