Help us understand the problem. What is going on with this article?

[Node.js][JavaScript]CryptoAPIの違いでハマったのでまとめ

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のパッケージがあるかもしれないが調べてない:joy:

Node.jsのCrypto

基本的には公式ドキュメントのコードがそのまま使用できる。
https://nodejs.org/api/crypto.html#crypto_class_cipher

大して見どころはないが、私のソースも載せておく。
ライブラリの方のソース。

nodeCrypto.js
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でなくてはいけないのかわからなくてハマった:persevere:
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

大して見どころはないが、私のソースも載せておく。
ライブラリの方のソース。

browserCrypto.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)ってちゃんとあるのに:weary:
ブラウザで暗号化する場合、key指定不要のgenerateKey()を利用するため、Node.jsのkeyを使えるのかもその時はわかっていなかった。

加えて生成されるCryptoKeyの中身が見れないのが、問題解決を遅らせた。
CryptoKeyがおかしいのか、decrypt()がおかしいのか見当がつかなかった。
これを間違わなければ1時間もあれば終わるようなもの。。。

Conclusion

JavaScriptは型を宣言しないとはいえ、builtinAPIはTypeScriptの型が見みれる。(複数の入力があるためどれがどれに対応するかはわからないが)
それにもかかわらず何とかなるだろうと、詳しく見ずにリトライを繰り返したのがよくなかった。

丁寧に見ていけば大丈夫…なはず。Node.jsは怖くない:relaxed:

Have a great day!

Appendices

今回のコードをブラウザで動かせるようにしたソースコード。
自分用なので少し不親切なのに注意。

ブラウザで動作確認(Node.APIはbrowserifyが使用される)

terminal

npm start

純粋なNode.APIでの確認

terminal

node -r esm ./src/cli.js

https://github.com/qrusadorz/example-decrypt-in-browser

qrusadorz
就業先では大規模サイト向けにAzure、プライベートでは中規模サイト向けにGCP(with Firebase)を運用中。フルスタックエンジニアとして、企画からマネタイズまで全工程に携わる。10年後にはサイトの収益のみで生活するセミリタイヤに向けて邁進中。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away