🗝️

Node.jsで可逆暗号

2023/04/20に公開

Node.js で可逆暗号を扱うための記事が意外になかったので書いておく。
平文で保管したくない文字列を暗号化する際に使う実装。

実装

crypto.ts
import crypto from "crypto";

const algorithm = "aes-256-ctr";
const secretKeyHex = process.env["SECRET_KEY"];
if (secretKeyHex === undefined) throw new Error("SECRET_KEY is not defined");
const secretKey = Buffer.from(secretKeyHex, "base64");

export const encrypt = (plain: string) => {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
  const encrypted = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);

  return Buffer.concat([iv, encrypted]);
};

export const decrypt = (encrypted: Buffer) => {
  const iv = encrypted.slice(0, 16);
  const value = encrypted.slice(16);

  const decipher = crypto.createDecipheriv(algorithm, secretKey, iv);
  const decrypted = Buffer.concat([decipher.update(value), decipher.final()]);

  return decrypted.toString("utf8");
};
generate-secret-key.ts
import crypto from "crypto";

console.log(crypto.randomBytes(32).toString("base64"));

実際にはテスタビリティのために、secretKey も引数にしておくのが良い。

参考情報

暗号利用モードに何を使うべきか

AES-256 を使うのは確定として、暗号利用モードは選択肢が様々あり難しい。結論としては CTR を使うので良さそう。理由を書いておく。

一番単純なモードが ECB で、脆弱な部分があるようだ。
それを修正したのが CBC で、一般的に利用されているが並列処理ができないという弱点がある。
それを並列処理できるようにしたのが CTR
というのがまぁざっくりした理解として間違ってなさそう。

さらに、GCM のような認証付き暗号というものがあるが、これは文字通り暗号文に認証性を持たせるためのモードで、TLS のような中間者攻撃を受けることが想定されるような場合に利用すべきもののようだ。

hex か base64 か

バイナリを文字列としてエンコードする際に、hex か base64 か悩むことが多い。
画像等の大きいデータを通信に乗っける場合は base64 一択だが、小さいデータをエンコードして環境変数なんかに乗っける場合は、データの長さが分かりやすい hex の方が好き。
それに、直感的に hex の方がエンコードが速いような気がする。

そこで計測してみたのだが、意外にも base64 の方が有意に速いことが分かった。
もちろん実行環境やらなんやらの影響はあると思うが、M1 Mac では何度試しても base64 の方が速くなった。
エンコードそのものの速度に大した差はなく、文字列としての長さの差がこの速度差に現れているような気がしてならない。

いずれにせよ環境変数を一度デコードするだけなら本来的には気にしなくてよいが、明確に速度差があることが分かってしまうと base64 にしておくか、となる。

import crypto from "crypto";
import { measure, dump } from "./measure";

const buffers: Buffer[] = [];
for (let i = 0; i < 10000; i++) {
  buffers.push(crypto.randomBytes(256));
}

const base64EncodedBuffers = buffers.map((buffer) => buffer.toString("base64"));
const hexEncodedBuffers = buffers.map((buffer) => buffer.toString("hex"));

measure("base64 encode", () => {
  for (let i = 0; i < 1000; i++) {
    for (const buffer of buffers) {
      buffer.toString("base64");
    }
  }
});
measure("base64 decode", () => {
  for (let i = 0; i < 1000; i++) {
    for (const base64EncodedBuffer of base64EncodedBuffers) {
      Buffer.from(base64EncodedBuffer, "base64");
    }
  }
});

measure("hex    encode", () => {
  for (let i = 0; i < 1000; i++) {
    for (const buffer of buffers) {
      buffer.toString("hex");
    }
  }
});
measure("hex    decode", () => {
  for (let i = 0; i < 1000; i++) {
    for (const hexEncodedBuffer of hexEncodedBuffers) {
      Buffer.from(hexEncodedBuffer, "hex");
    }
  }
});

dump();
% npx ts-node main.ts
base64 encode : 2475ms
base64 decode : 3520ms
hex    encode : 2867ms
hex    decode : 3920ms
measure.ts
export const measure = <T>(label: string, handler: () => T): T => {
  performance.mark("start");
  const result = handler();
  performance.mark("finish");
  performance.measure(label, "start", "finish");
  return result;
};
export const dump = () => console.info(performance.getEntriesByType("measure").map(({name, duration}) => `${name} : ${Math.floor(duration)}ms`).join("\n"));

蛇足(ChatGPT すごい)

Node.js で可逆暗号の実装を書くのは初めてだったので ChatGPT に色々聞きながら進めた。
相変わらず ChatGPT のおかげで生産性が爆上がりしている。



GitHubで編集を提案

Discussion