はじめに
Torinoa Tools という個人開発の開発者向けツール集の中に、ブラウザだけでAES暗号化・復号ができるAES Encrypt / Decryptツールがあります。
CryptoJSのような外部ライブラリは使わず、ブラウザ標準の SubtleCrypto(Web Crypto API)だけで実装しています。この記事では、その実装の中身を紹介します。
なぜ外部ライブラリを使わないか
- ブラウザ完結ツールとして配布サイズを増やしたくない
-
SubtleCryptoはネイティブ実装なので、JSでの実装より高速・安全 - 依存が少ないほどメンテナンスが楽
一方で、SubtleCrypto は低レベルAPIなので、パスフレーズから鍵を作る部分や、salt/IVの管理は自前で組む必要があります。
パスフレーズから鍵を導出する(PBKDF2)
ユーザーが入力するのは「パスフレーズ」であって、AESの生の鍵ではありません。そこでPBKDF2で鍵導出しています。
const PBKDF2_ITERATIONS = 100_000;
async function deriveKey(
passphrase: string,
salt: Uint8Array,
algorithm: AesAlgorithm,
keySize: AesKeySize,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(passphrase),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
baseKey,
{ name: algorithm, length: keySize },
false,
["encrypt", "decrypt"],
);
}
ポイントは2段階になっていることです。
-
importKey("raw", ..., "PBKDF2", ...)でパスフレーズをPBKDF2用の「鍵の材料」として読み込む -
deriveKey(...)で実際にAES用の鍵を導出する
反復回数は10万回(SHA-256)にしています。ブラウザ内で完結させる都合上、あまり回数を増やすとUIがブロックされるため、セキュリティと体感速度のバランスを見て決めた数値です。
saltとIVを暗号文に埋め込む自己完結フォーマット
AESは同じ鍵・同じIVで暗号化すると危険なので、暗号化のたびに新しいsaltとIVをランダム生成する必要があります。復号時にこれらが必要になるため、暗号文と一緒に1本のバイト列にまとめてBase64化しています。
export async function aesEncrypt(
plaintext: string,
passphrase: string,
opts: AesOptions,
): Promise<AesEncryptResult> {
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(ivLengthFor(opts.algorithm)));
const key = await deriveKey(passphrase, salt, opts.algorithm, opts.keySize);
const cipherBuffer = await crypto.subtle.encrypt(
opts.algorithm === "AES-GCM"
? { name: "AES-GCM", iv }
: { name: "AES-CBC", iv },
key,
new TextEncoder().encode(plaintext),
);
const ciphertext = new Uint8Array(cipherBuffer);
const header = new Uint8Array([algoToByte(opts.algorithm), opts.keySize / 8]);
const combined = new Uint8Array(
HEADER_LENGTH + salt.length + iv.length + ciphertext.length,
);
combined.set(header, 0);
combined.set(salt, HEADER_LENGTH);
combined.set(iv, HEADER_LENGTH + salt.length);
combined.set(ciphertext, HEADER_LENGTH + salt.length + iv.length);
return { combined: uint8ToBase64(combined), /* ...hex表示用の内訳 */ };
}
バイナリレイアウトはこうなっています。
[アルゴリズム種別(1byte)] [鍵長/8(1byte)] [salt(16byte)] [IV(12 or 16byte)] [暗号文]
先頭2バイトにアルゴリズムと鍵長を持たせているのは、復号時にAES-GCMかAES-CBCか、鍵長は128/192/256のどれかを、暗号文自体から判別できるようにするためです。これにより、復号する側は「パスフレーズ」だけ入力すればよく、アルゴリズムや鍵長を別途指定させる必要がありません。UIがシンプルになる、実装上のちょっとした工夫です。
復号とタンパリング検知はタダでついてくる
export async function aesDecrypt(
combinedBase64: string,
passphrase: string,
): Promise<string> {
const combined = base64ToUint8(combinedBase64.trim());
const algorithm = byteToAlgo(combined[0]);
const keySize = (combined[1] * 8) as AesKeySize;
// ... salt, iv, ciphertext を切り出す
const key = await deriveKey(passphrase, salt, algorithm, keySize);
// パスフレーズ違いや破損データの場合、SubtleCryptoが例外を投げる
// (AES-GCMは認証タグの検証も兼ねるため改ざん検知にもなる)
const plainBuffer = await crypto.subtle.decrypt(
algorithm === "AES-GCM" ? { name: "AES-GCM", iv } : { name: "AES-CBC", iv },
key,
ciphertext,
);
return new TextDecoder().decode(plainBuffer);
}
AES-GCMは暗号文の末尾に認証タグ(MAC)を含む認証付き暗号(AEAD)なので、パスフレーズが違う・データが壊れている・改ざんされている、のどれであっても crypto.subtle.decrypt が単に例外を投げてくれます。自分で改ざん検知ロジックを書く必要がありません。これはWeb Crypto APIを直接使う一番のメリットだと感じています。
注意点:バイナリ互換は意図していない
このフォーマットは「このツール自身で暗号化・復号が完結する」ことを目的にした自己完結フォーマットであり、OpenSSLなど外部ツールとのバイナリ互換は意図していません。例えば openssl enc -aes-256-cbc で作った暗号文をこのツールに貼り付けても復号できません(ヘッダーのフォーマットが違うため)。あくまで「ブラウザだけで暗号化・復号を完結させたい」というユースケースに特化しています。
おわりに
Web Crypto APIは低レベルなぶん、鍵導出やフォーマット設計は自分で考える必要がありますが、その分「何が起きているか」を細かく制御でき、外部ライブラリへの依存も減らせます。ブラウザで動く暗号ツールを作る際の参考になれば幸いです。
ツールはこちらから試せます: https://tools.torinoa.com/tools/aes-encrypt-decrypt/