🔐

共通鍵暗号をTypeScriptで学ぶ

に公開

共通鍵暗号とは

データを暗号化して扱いたいことがあります。暗号化の処理にはアルゴリズムを用意します。暗号化されたデータは鍵を知らない他人には読んだり使用することができません。暗号化されたデータを元の使用可能なデータに戻すことを復号と呼びます。復号にも同様に鍵が必要です。鍵を持っている人だけがその暗号化データを扱えることを意味します。暗号化していないデータのことを平文と呼びます。

暗号化と復号でまったく同じ鍵が必要な暗号方式のことを共通鍵暗号と呼びます。信用できる人だけに鍵を渡しておけば、その人と自分だけが理解できる通信を実現可能です。

暗号アルゴリズムは色々ありますが、ここではAESに絞って遊んでみたいと思います。

AES (Advanced Encryption Standard)

共通鍵暗号の一種です。特定のアルゴリズムの名前ではないのですが、こまけぇこたぁいいです。2025年時点では、共通鍵暗号をやるならこれを使っておけば良いやつだと思います。

鍵のサイズは128ビット、192ビット、256ビットの3種類です。鍵のビット数が大きいほど暗号文が破られにくくなりますが、計算量も増えます。一般的なアプリケーションでは128ビットで十分な強度のようです。

AESはブロック暗号という種類の暗号方式で、平文を128ビットのブロックに分割してひとつずつ暗号処理を行います。この1ブロック128ビットというのはAESで固定なので、鍵長が192ビットだろうが256ビットだろうが1ブロックのサイズは128ビットです。

TypeScriptで使う

TypeScriptで実際にデータを暗号化したり暗号文を復号したりして遊んでみます。

Node.jsを使いますが、Node.jsには暗号用モジュールが大きく2つあります。Web Crypto APIとNode.js標準のcryptoモジュールです。

Web Crypto API はグローバルにいる crypto 変数に生えているAPIです。名前から予想できる通り、ブラウザJavaScriptが出自です。ブラウザ用に標準化されたAPIですが、Node.jsも実装しているので同じ使い勝手で使用できます。また、Cloudflare WorkersやDenoなどの新進気鋭JavaScriptランタイムも実装しており、ポータビリティに富みます。

Node.js標準のcryptoモジュールは当然Node.js専用です。import crypto from "node:crypto" のようにモジュールを読み込んでから使います。

この記事では多くのランタイムで同じ知識が活用できるようにWeb Crypto APIで試していきます。

早速、人間が読めるテキストデータを暗号化して、そのまま復号するスクリプトを書いてみます。

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

const cryptoKey = await crypto.subtle.generateKey(
  { name: "AES-CTR", length: 128 },
  true,
  ["encrypt", "decrypt"],
);

const counter = crypto.getRandomValues(new Uint8Array(16));

const plaintext = textEncoder.encode(
  "ぼくはまちちゃん! こんにちはこんにちは!!",
);

const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-CTR",
    counter: counter,
    length: 64,
  },
  cryptoKey,
  plaintext,
);

console.log("Encrypted:", textDecoder.decode(encryptedText));

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-CTR",
    counter: counter,
    length: 64,
  },
  cryptoKey,
  encryptedText,
);

console.log("Decrypted:", textDecoder.decode(decryptedText));

このスクリプトを実行すると次のような出力が確認できます。

Encrypted: �>��� h/��2x&@|����ȏMq���J�����x/㳒�+���_,�,��Uܳ�
Decrypted: ぼくはまちちゃん! こんにちはこんにちは!!

暗号文はぐちゃぐちゃと文字化けしていますね(ランダム値を含むので実行のたびに変わります)。そして復号したデータは平文と一致していることもわかります。

鍵とは別にcounterという値を生成して使っているのが見えますが、これは後で説明するので一旦無視します。

それでは各処理について説明します。

暗号鍵の生成

const cryptoKey = await crypto.subtle.generateKey(
  { name: "AES-CTR", length: 128 },
  true,
  ["encrypt", "decrypt"],
);

ここでは、暗号に使用する鍵を生成しています。

暗号鍵の生成には crypto.subtle.generateKey という関数が用意されています。共通鍵暗号だけでなく、公開鍵暗号用の鍵生成にも同じ関数が使えるようになっています。

第1引数には暗号アルゴリズムの名前と鍵長を指定します。アルゴリズム名には "AES-CTR" を指定しましたが、CTRについては後ほど。とにかくAES用の鍵です。鍵長は128ビットを指定しました。

第2引数には鍵をエクスポート可能にするかどうかを指定します。trueにすると、crypto.subtle.exportKeyを使ってJSON Web Keyなどに変換してエクスポートできます。ブラウザで生成してサーバーに保存したり、公開鍵を誰かに譲渡するなどのユースケースが考えられます。falseにすると鍵をエクスポートできなくなりますが、ブラウザ内ストレージのIndexedDBには保存可能です。生成した暗号鍵がそのブラウザに閉じて利用されるのであればfalseにしておくのが良いでしょう。

第3引数には、その鍵で実行できる処理を指定します。暗号化と復号を行いたいので "encrypt""decrypt"を指定しました。他にはデジタル署名やDH鍵交換などの用途も指定できます。鍵の生成時点で用途を限定させ、指定外の用途で使おうとするとエラーになるのは安全で良いですね。

一般に、暗号用の鍵は完全にランダムな値であること(予測不可能性)が求められます。JavaScriptでランダムな値の取得というと真っ先にMath.random()の使用を思いつくと思いますが、これは暗号用途で使ってはいけません。Math.random()は予測可能な擬似乱数生成器であり、暗号用途で使うのは危険です。実際にMath.random()の出目を予測する方法?知らないねぇ…。

とはいえ、Math.random()はnumberを生成するのに対してcrypto.subtle.generateKeyは鍵を内包するCryptoKey | CryptoKeyPair型オブジェクトを生成するため、型の違いですぐに使えないことがわかります。安心ですね。

counterの生成

const counter = crypto.getRandomValues(new Uint8Array(16));

counterが何かは後ほど説明しますが、ここでは16バイトのランダムな値を生成する必要があることだけ知っておいてください。

鍵の生成と同じく、暗号用途のランダム値を生成するためにcrypto.getRandomValuesを使用しています。ただし暗号鍵とは異なり、counterは秘密にすべき値ではありません。実際のアプリケーションでは、暗号済みデータとcounterをセットにして保存しておくことになります。例えば、暗号済みデータのバイト列とcounterもバイト列を連結してからどこかに保存することができます。

平文の処理

const plaintext = textEncoder.encode(
  "ぼくはまちちゃん! こんにちはこんにちは!!",
);

暗号対象の平文は実際はバイナリデータとして扱います。今回はテキストを暗号化するため、TextEncoderを使ってバイト列(Uint8Array)に変換しています。

たいていのデータ(画像、動画、etc)はUint8Arrayに変換することができます。つまり、文字列以外のデータも暗号化して秘密にすることができます。

暗号化

const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-CTR",
    counter: counter,
    length: 64,
  },
  cryptoKey,
  plaintext,
);

crypto.subtle.encryptを使って暗号化を行います。

第1引数には暗号アルゴリズムとcounterlengthを指定します。lengthについても後ほど説明します。

第2引数には暗号化に使用する鍵を、第3引数には暗号化対象の平文を指定します。

復号

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-CTR",
    counter: counter,
    length: 64,
  },
  cryptoKey,
  encryptedText,
);

crypto.subtle.decryptを使って復号を行います。

第1引数には暗号化と同じくアルゴリズム名、counterlengthを指定します。ここで、counterlengthには暗号化時に使用した値と同じ値を使う必要があることに注意してください。

第2引数には復号に使用する鍵を、第3引数には復号対象の暗号文を指定します。

ここまでで、平文をAESで暗号化して復号するまでの処理が完了しました!

暗号利用モード

AESは128ビットごとのブロックに分けて暗号化を行うと説明しました。

暗号化対象の平文が128ビットより大きい場合、暗号化処理をそのブロックの数だけ繰り返すことになります。ここで、まったく同じ処理を各ブロックに繰り返したらどうなるでしょうか?同じ128ビットが何度も繰り返されるような平文を暗号化した場合、暗号結果も同じように何か繰り返されたような痕跡が見えることになります。

そのような平文の情報が漏れ出す事態を防ぐために、平文のブロック毎に異なる値を混ぜ込んで暗号化するのが一般的です。どんな値の混ぜ方をするかのことを暗号利用モードと呼びます。

ECBモード

ECB(Electronic CodeBook)モードは、ブロックに何も混ぜないモードです。つまり、128ビット毎のブロックを毎回同じ方法で暗号化します。同じ平文ブロックは同じ暗号結果となるため、平文に同じ値が繰り返されていると、そのパターンがそのまま暗号文に現れる可能性があります。

ECBモードで暗号化すると平文の情報が暗号文に残ることになるので、使用は避けるべきです。幸い、Web Crypto APIではECBモードは選択できないようになっています。

ECBモードを使うと暗号文に平文の特徴が浮き出ることを確認するために、TypeScriptで試してみました。ECBモードを選択できてしまうNode.jsのcryptoモジュールを使います。

"こんにちは!こんにちは!こんにちは!こんにちは!"という平文をAES ECBモードで暗号化します。UTF-8でひらがなは3バイト、半角エクスクラメーション記号は1バイトです。"こんにちは!"は16バイト(128ビット)になり、ちょうどAESのブロックサイズと一致します。それを4回繰り返した平文となっています。

import crypto from "node:crypto";

const key = crypto.randomBytes(16); // 128ビットの暗号鍵
const plaintext = "こんにちは!こんにちは!こんにちは!こんにちは!"; // 128ビットの繰り返し

// Encrypt
const cipher = crypto.createCipheriv("aes-128-ecb", key, null);
const encrypted = Buffer.concat([
  cipher.update(plaintext, "utf8"),
  cipher.final(),
]);

// Decrypt
const decipher = crypto.createDecipheriv("aes-128-ecb", key, null);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);

console.log("Encrypted:", encrypted.toString("hex"));
console.log("Decrypted:", decrypted.toString("utf8"));

次のテキストが表示されており、繰り返しがあることがわかるように 697b にハイライトがかかっている: Encrypted: d0482214441431ad8aac26ba681da188d0482214441431ad8aac26ba681da188d0482214441431ad8aac26ba681da188d0482214441431ad8aac26ba681da1884d7d4b988a9e7b5c234198ffc3ff32ce
Decrypted: こんにちは!こんにちは!こんにちは!こんにちは!

hex形式で表示された暗号文には確かに繰り返しのパターンが見えますね。誰にも中身がわからないように暗号化したはずなのに、暗号文からわずかに平文の情報が漏れ出しているということになります。

Wikipedia のECBの説明には画像を暗号化することによる、より直感的にわかりやすい例が載っています。バターンの残留によって、元の画像に何が描かれていたのかがわかってしまうのがわかります。

https://ja.wikipedia.org/wiki/暗号利用モード#Electronic_Codebook_(ECB)

ということで、AESを使う際にECBモードを選択するのはやめましょう。Web Crypto APIならそもそもサポートされていないため安心です。

CBCモード

CBC(Cipher Block Chaining)モードは、一つ前のブロックの暗号化結果を次のブロックに混ぜ込んでから暗号化するモードです。直前の暗号ブロックに依存するため、同じ平文ブロックであっても直前ブロックが異なれば異なる暗号ブロックが得られます。

最初のブロックには直前のブロックが存在しないので、最初のブロックに混ぜ込むための初期化ベクトル(Initialization Vector, IV)と呼ばれるランダムな128ビットの値を渡します。暗号化のたびに異なるIVを渡すことで、連鎖的に異なる暗号文を得ることができます。

CBCモードの暗号化ステップのイメージは次のようになります。


初期化ブロック -> XOR
平文ブロック1 -> XOR
XOR -> 暗号化 -> 暗号ブロック1
暗号ブロック1 -> XOR
平文ブロック2 -> XOR
XOR -> 暗号化 -> 暗号ブロック2
暗号ブロック2 -> XOR
平文ブロック3 -> XOR
XOR -> 暗号化 -> 暗号ブロック3
暗号ブロック3 -> XOR
平文ブロック4 -> XOR
XOR -> 暗号化 -> 暗号ブロック4
暗号技術入門 第3版を参考に作図

ここで XOR は排他的論理和を意味します。ビット演算のひとつですが、値を混ぜ込む処理に相当すると思えばよいです。

AESのCBCモードによる暗号をTypeScriptで試してみましょう。

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

const cryptoKey = await crypto.subtle.generateKey(
  { name: "AES-CBC", length: 128 },
  true,
  ["encrypt", "decrypt"],
);

const iv = crypto.getRandomValues(new Uint8Array(16));

const plaintext = textEncoder.encode(
  "ぼくはまちちゃん! こんにちはこんにちは!!",
);

const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-CBC",
    iv: iv,
  },
  cryptoKey,
  plaintext,
);

console.log("Encrypted:", textDecoder.decode(encryptedText));

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-CBC",
    iv: iv,
  },
  cryptoKey,
  encryptedText,
);

console.log("Decrypted:", textDecoder.decode(decryptedText));

実行すると次のような出力になります。

Encrypted: a�a��n��(����M��zH��^@�w��   �ouy�y��8�c+�ߎf�fUjv�f؉��DQ
Decrypted: ぼくはまちちゃん! こんにちはこんにちは!!

CBCモードを使用する場合は、generateKey, encrypt, decryptで渡すアルゴリズム名に"AES-CBC"を指定します。この3つが一致していないとエラーになるので注意してください。

crypto.subtle.generateKey({ name: "AES-CBC", length: 128 } /*...*/);
crypto.subtle.encrypt({ name: "AES-CBC" } /*...*/);
crypto.subtle.decrypt({ name: "AES-CBC" } /*...*/);

そして注目すべきは初期化ベクトルivです。ivは暗号化を行うたびにランダムに生成する必要があります。暗号用途のランダム値の生成なのでcrypto.getRandomValuesを使います。ただし、ivは秘密にする必要はありません。暗号化されたデータとセットで保存しておくことになります。

const iv = crypto.getRandomValues(new Uint8Array(16));

const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-CBC",
    iv: iv,
  },
  cryptoKey,
  plaintext,
);

復号にも同じivを渡します。もちろん暗号鍵も暗号化時と同じものを渡す必要があります。

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-CBC",
    iv: iv,
  },
  cryptoKey,
  encryptedText,
);

これでCBCモードによる暗号化と復号ができました。

CTRモード

CTR(Counter)モードは、平文を128ビットのブロックに分けたときのブロックのインデックス(前から何番目かの数)だけカウントアップしたカウンターを平文に混ぜて暗号化します。単にゼロから始まるカウンターとブロックのインデックスだけだと結局同じ平文ブロックが同じ暗号文ブロックになってしまうので、それを避けるために暗号処理開始時に生成したランダムな値をカウンターの初期値として使います。

カウンターの初期値にはブロックサイズと同じ128ビットのランダムな値を渡します。そのうち上位いくつかのビットはすべてのブロックで固定の値として扱われます(固定の部分をnonceと呼びます)。残りの下位ビットがブロックのインデックスによってカウントアップされる部分です(本記事ではカウンター部と呼ぶことにします)。通常は128ビットの初期値を64ビットずつに分けて、nonceとカウンター部とみなすことが多いようです。

CTRモードのイメージを図にすると次のようになります。


カウンター -> 暗号化 -> XOR
平文ブロック1 -> XOR
XOR -> 暗号ブロック1
カウンター +1 -> 暗号化 -> XOR
平文ブロック2 -> XOR
XOR -> 暗号ブロック2
カウンター +2 -> 暗号化 -> XOR
平文ブロック3 -> XOR
XOR -> 暗号ブロック3
カウンター +3 -> 暗号化 -> XOR
平文ブロック4 -> XOR
XOR -> 暗号ブロック4
暗号技術入門 第3版を参考に作図

CTRモードのCBCモードに対する優位点は、各ブロックが独立して暗号処理されることです。そのブロックのインデックスさえわかれば暗号処理を実行できるため、実装次第では並列処理が可能です。

お察しの通り、この記事の最初で紹介したコードはCTRモードを使用していました。

AESのCTRモードはgenerateKey, encrypt, decryptで渡すアルゴリズム名に"AES-CTR"を指定します。この3つが一致していないとエラーになるので注意してください。

crypto.subtle.generateKey({ name: "AES-CTR", length: 128 } /*...*/);
crypto.subtle.encrypt({ name: "AES-CTR" } /*...*/);
crypto.subtle.decrypt({ name: "AES-CTR" } /*...*/);

そしてカウンターとして使う乱数counterを生成して使用します。counterは暗号化を行うたびにランダムに生成する必要があります。暗号用途のランダム値の生成なので必ずcrypto.getRandomValuesを使いましょう。ただしcounterは秘密にする必要はありません。暗号化されたデータとセットで保存しておくことになります。

const counter = crypto.getRandomValues(new Uint8Array(16));
const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-CTR",
    counter: counter,
    length: 64,
  },
  cryptoKey,
  plaintext,
);

counterとともに指定しているlengthが、counterの値のうち何ビットをカウンター部のビットとして扱うかを指します。例えばlength: 28と指定すると、counterのうち下位28ビットでカウントアップが行われ、上位100ビットはnonceとして固定されたまま使われます。通常はnonceとカウンター部を64ビットずつにすることが多いので、length: 64としています。

復号にも同じcounterを渡し、同じlengthの値を指定します。もちろん暗号鍵も暗号化時と同じものを渡す必要があります。

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-CTR",
    counter: counter,
    length: 64,
  },
  cryptoKey,
  encryptedText,
);

これでCTRモードによる暗号化と復号ができました。

GCMモード

GCM(Galois/Counter Mode)モードは、CTRモードに認証機能を追加したものです。CTRモードは暗号化の処理を行うだけで、暗号化されたデータが改竄されていないかどうかの確認は行いません。GCMモードでは、データの暗号化と認証を同時に行います。認証機能を追加することで、暗号化されたデータが改竄されていないかどうかを確認することができます。

暗号文なのに改竄できるとは不思議な話ですが、実際に可能です。自分で暗号化したデータの先頭ビットを反転したサンプルを用意してみます。

// AES-CBC で encryptedText を生成するコードを省略しています

const alteredEncryptedText = new Uint8Array(encryptedText);
alteredEncryptedText[0] ^= 1; // 1ビット目を反転

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-CBC",
    iv: iv,
  },
  cryptoKey,
  alteredEncryptedText,
);

console.log("Decrypted:", textDecoder.decode(decryptedText));

このコードを実行すると、エラーになると思いきや正常終了して次のような出力が得られます。

Decrypted: }G��8�����ゃん! こんにちはこんにちは!!

どうみても失敗してるよ〜と言いたいのですが、この復号結果が失敗なのかどうかは復号する側では判断できないのですね。

通常、暗号が必要とされるケースでは送信者と受信者が別であり、送信者が暗号化したメッセージを受信者が復号する流れになります。つまり、本当に送りたかった内容を知っているのは送信者だけです。受信者は受け取る暗号文が伝達中に改竄されていても気づくことはできず、受信者は送信者が「}G��8�����ゃん! こんにちはこんにちは!!」と伝えたかったんだと信じるしかありません。

これでは困るため、GCMモードでは、暗号化されたデータに認証タグを付与して、受信者が改竄されていないかどうかを確認できるようにしています。

TypeScriptでGCMモードを試してみましょう。次のコードは正常に暗号化して復号するコードで、成功します。

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

const cryptoKey = await crypto.subtle.generateKey(
  { name: "AES-GCM", length: 128 },
  true,
  ["encrypt", "decrypt"],
);

const iv = crypto.getRandomValues(new Uint8Array(12)); // 96ビット

const plaintext = textEncoder.encode(
  "ぼくはまちちゃん! こんにちはこんにちは!!",
);

const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-GCM",
    iv: iv,
    additionalData: textEncoder.encode("CSRFにきをつけて"),
  },
  cryptoKey,
  plaintext,
);

console.log("Encrypted:", textDecoder.decode(encryptedText));

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-GCM",
    iv: iv,
    additionalData: textEncoder.encode("CSRFにきをつけて"),
  },
  cryptoKey,
  encryptedText,
);

console.log("Decrypted:", textDecoder.decode(decryptedText));

実行すると次のような出力になります。

Encrypted: �L-��x��Ώ˹*g�@Ci��u��}����Sķjs�<j�3�$���`'%���������21]�����+Aכ
Decrypted: ぼくはまちちゃん! こんにちはこんにちは!!

アルゴリズム名に "AES-GCM" を指定するのは他のモードと同じです。

初期化ベクトルivは96ビットのランダムな値を生成して渡します。96ビットはGCMの初期化ベクトルとして推奨されています。

電子政府における調達のために参照すべき暗号のリスト(CRYPTREC暗号リスト)(PDFへのリンクです)

(注4) 初期化ベクトル長は96ビットを推奨する。

暗号化時にadditionalDataが渡せます。これは暗号化はされませんが、認証タグの計算に使用されます。つまり検証に必要なデータになるので、暗号文やivとともに覚えておく必要があります。

const encryptedText = await crypto.subtle.encrypt(
  {
    name: "AES-GCM",
    iv: iv,
    additionalData: textEncoder.encode("CSRFにきをつけて"),
  },
  cryptoKey,
  plaintext,
);

復号はadditionalDataを渡すこと以外は他のモードと同じです。

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-GCM",
    iv: iv,
    additionalData: textEncoder.encode("CSRFにきをつけて"),
  },
  cryptoKey,
  encryptedText,
);

これでGCMモードによる暗号化と復号ができました。

続いて、暗号文が改竄された場合を試してみましょう。

// AES-GCM で encryptedText を生成するコードを省略しています

const alteredEncryptedText = new Uint8Array(encryptedText);
alteredEncryptedText[0] ^= 1; // 1ビット目を変更

const decryptedText = await crypto.subtle.decrypt(
  {
    name: "AES-GCM",
    iv: iv,
    additionalData: textEncoder.encode("CSRFにきをつけて"),
  },
  cryptoKey,
  alteredEncryptedText,
);

console.log("Decrypted:", textDecoder.decode(decryptedText));

このコードを実行すると、次のようなエラーが発生します。

node:internal/crypto/util:445
    return reject(lazyDOMException(
                  ^
DOMException [OperationError]: The operation failed for an operation-specific reason
    at AESCipherJob.onDone (node:internal/crypto/util:445:19) {
  [cause]: [Error: Cipher job failed]
}

OperationErrorというエラーが発生しました。これは復号中の検証に失敗したことを示しています。CBCではビットが改竄されていても復号に成功したように見えましたが、GCMでは明確にエラーになりました。これならどこかで暗号文が改竄されても気づかずに使い続ける心配はありませんね。

まとめ

共通鍵暗号のAESをTypeScriptで扱う方法を紹介しました。

Web Crypto APIを使うことで、ブラウザ上でもAESを使った共通鍵暗号を利用できます。

鍵や初期化ベクトルなどランダム生成には専用のAPIがあります。暗号用途での乱数生成は必ずcrypto.getRandomValuesを使いましょう。間違ってもMath.random()を使ってはいけません。

同じAESでも暗号利用モードによって必要なパラメーターやそのサイズが異なります。

それではよい暗号ライフを!

GitHubで編集を提案

Discussion