PageCryptベースのHTML暗号化を確認する
HTMLページをパスワード保護したい場合、Basic認証やDigest認証などサーバー設定が必要な手法を使うのが一般的です。 しかし、"静的ホスティング上で完結させたい"といったシーンでは、PageCrypt1のようにクライアント側で復号する仕組みが有用です。
PageCryptは、HTMLファイルを事前に暗号化し、JavaScriptで復号化することで、サーバーの追加設定を不要にしています。
本記事では、PythonとJavaScriptでPageCrypt相当の処理を再現しながら、AES-GCM暗号とPBKDF2(あるいはargon2)によるキー導出を解説します。
Pythonの暗号化ライブラリには pycryptodome2 を、JavaScriptの復号には Web Crypto API を使用しています。
全体の流れ
以下のようなフローで、パスワードと平文(HTML)を用いて、AES-GCMで暗号化します。

- 平文とパスワードを用意する
- PBKDF2を用いてパスワードから鍵(key)を生成
- 生成した鍵を利用して、AES-BCMで暗号化を行う
- salt、IV3、暗号文と認証タグ(tag)を1つにまとめる
上記フローからもわかるように、パスワードを直接AESの鍵として使わないです。 あくまで、パスワードを利用し、PBKDF2を通して鍵を作成し、安全に暗号化する設計になっています。
実際の運用では「salt」「IV」「暗号文」「認証タグ」の4セットをHTMLに埋め込みます。 ユーザーがページを開いてパスワードを入力すると、JavaScriptがそれらの情報を使って復号処理を行い、ページを展開する仕組みです。
AES-GCM による暗号化/復号化
AESにはCBCやCTRなど複数のモードがありますが、ここではAES-GCM(Galois/Counter Mode)を利用します。
AES-GCMは、認証タグ(MAC)が自動的に生成・検証される「認証付き暗号」であり、改ざん検知が標準で行えるのが特徴です。
まずは「hello」を暗号化・復号する簡単なサンプルコードを示します。
import base64
import os
from Crypto.Cipher import AES
def encrypt(key: bytes, plaintext: bytes) -> bytes:
iv = os.urandom(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
return iv + ciphertext + tag
def decrypt(key: bytes, iv: bytes, ciphertext: bytes, tag: bytes) -> bytes:
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
return cipher.decrypt_and_verify(ciphertext, tag)
if __name__ == "__main__":
plaintext = b"hello"
key = b"0"*16
encrypted_data = encrypt(key, plaintext)
final_encrypted_data = base64.b64encode(encrypted_data)
print("Encrypted data:", final_encrypted_data)
iv = encrypted_data[:12]
ciphertext = encrypted_data[12:-16]
tag = encrypted_data[-16:]
print("Decrypted data:", decrypt(key, iv, ciphertext, tag))# Derived Key: 30303030303030303030303030303030
# Encrypted data: b'7GYaE4mekELCvfQHhONbxe7JSejHy/nW/LfkQ5Fu9ks8'
# Decrypted data: b'hello'
"hello"が暗号化され、復号により元通り"hello"に戻っています。
改ざん検知の例
暗号文の一部を改ざんした場合、認証タグとの照合が失敗し、.decrypt_and_verify()はエラーを吐きます。
試しに暗号文の一部を書き換えます。
key = b"0"*16
encrypted_data = bytearray(b"7GYaE4mekELCvfQHhONbxe7JSejHy/nW/LfkQ5Fu9ks8")
print("Decrypted data:", decrypt(key, encrypted_data))
encrypted_data[12] ^= 0x01 # 🔥
try:
print("Decrypted data:", decrypt(key, encrypted_data))
except ValueError as e:
print("Error:", e)# Decrypted data: b'hello'
# Error: MAC check failed
このように、改ざんを検知して復号できなくなります。
JavaScriptで復号する
上記Pythonで暗号化したデータを、JavaScriptのWeb Crypto APIで復号する例です。
async function deriveKey() {
const keyBytes = new Uint8Array(16).map((_, i) => "0".charCodeAt(0));
return await crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "AES-GCM", length: 128 },
false,
["decrypt"]
);
}
async function decrypt(key, iv, ciphertextAndTag) {
try {
const decryptedArrayBuffer = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: iv },
key,
ciphertextAndTag
);
const decryptedText = new TextDecoder().decode(decryptedArrayBuffer);
console.log(`Decrypted data: ${decryptedText}`);
} catch (e) {
console.error("復号エラー:", e);
}
}
(async () => {
const encryptedData = "7GYaE4mekELCvfQHhONbxe7JSejHy/nW/LfkQ5Fu9ks8";
const binaryData = Uint8Array.from(atob(encryptedData), (c) =>
c.charCodeAt(0)
);
const iv = binaryData.slice(0, 12);
const ciphertextAndTag = binaryData.slice(12);
const key = await deriveKey();
decrypt(key, iv, ciphertextAndTag);
})();Decrypted data: hello
問題なく復号できています。
PBKDF2
AES-GCMで利用する鍵の生成には、PBKDF2を使うのが一般的です。
PBKDF2はパスワードとsaltを用い、ハッシュ計算を複数回繰り返すことで、攻撃を困難にする仕組みです。
AES鍵は16バイト(128ビット)や32バイト(256ビット)の長さが必要ですが、ユーザー入力のパスワードは長さや強度がバラバラなので、PBKDF2で安全に鍵を導出します。
以下の例ではPBKDF2を10万回反復し、AES-256(32バイト)の鍵を導出しています。
from Crypto.Hash import SHA256
from Crypto.Protocol.KDF import PBKDF2
def derive_key(password: str, salt: bytes) -> bytes:
return PBKDF2(
password,
salt,
dkLen=32,
count=100000,
hmac_hash_module=SHA256,
)
暗号化結果には暗号文の先頭にsaltも加えて出力し、復号時はそれを取り出して同じ鍵導出を行います。
password = "mypassword"
plaintext = b"hello"
salt = os.urandom(16)
key = derive_key(password, salt)
encrypted_data = encrypt(key, plaintext)
final_encrypted_data = base64.b64encode(salt + encrypted_data)
print("Salt:", salt.hex())
print("Derived Key:", key.hex())
print("Base64 Encoded Data:", final_encrypted_data)# Salt: 8094f8bc4474837fbe7fd0a6b3777cd1
# Derived Key: 93c9bd309716836ce01b954297e4ca99a5c5d30a080fe3b0df09dbe92b1e1963
# Base64 Encoded Data: b'gJT4vER0g3++f9Cms3d80ZX2181OikMw7IADnsZlg11rqY8wqVrOsY2cCnANGWFq1g=='
JavaScript側で同じくPBKDF2を使えば復号できます。
async function deriveKey(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveKey"]
);
return await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
}
async function decrypt(key, iv, ciphertextAndTag) {
}
(async () => {
const encryptedData =
"gJT4vER0g3++f9Cms3d80ZX2181OikMw7IADnsZlg11rqY8wqVrOsY2cCnANGWFq1g==";
const password = "mypassword";
const binaryData = Uint8Array.from(atob(encryptedData), (c) =>
c.charCodeAt(0)
);
const salt = binaryData.slice(0, 16);
const iv = binaryData.slice(16, 28);
const ciphertextAndTag = binaryData.slice(28);
const key = await deriveKey(password, salt);
decrypt(key, iv, ciphertextAndTag);
})();Decrypted data: helloargon2
オリジナルのPageCryptにはありませんが、argon2を使ったキー導出に差し替えることもできます。
2015年のパスワードハッシュコンペティション4の優勝者であるArgon2は、PBKDF2の代替として利用可能です。 たとえば、BitwardenだとArgon2idを実装しています5。
以下の例でも2idを利用しています。
Pythonではargon2-cffi6、JavaScriptではargon2-browser7を利用します。
基本的な部分はPBKDF2の場合と同じで、以下のように鍵を生成します。
from argon2.low_level import Type, hash_secret_raw
def derive_key(password: str, salt: bytes) -> bytes:
return hash_secret_raw(
secret=password.encode(),
salt=salt,
time_cost=3,
memory_cost=65536,
parallelism=1,
hash_len=32,
type=Type.ID,
)# Salt: d10b6d0d2f02808426efaedfc2453896
# Derived Key: 3d7b67a07cb91dce957eda7b2903502de7de8e68e0fa9f616278afcfe3f8d74d
# Base64 Encoded Data: b'0QttDS8CgIQm767fwkU4lvgFvJZ95BYFJPjPa/MKXwKh0LwEuZeye2BSzMiCD5r7ZQ=='
JavaScript側では argon2-browserをCDNで読み込み、同様にキーを生成することで復号可能です。
async function deriveKey(password, salt) {
const hash = await argon2.hash({
pass: password,
salt: salt,
time: 3,
mem: 65536,
hashLen: 32,
parallelism: 1,
type: argon2.ArgonType.Argon2id,
});
return await crypto.subtle.importKey(
"raw",
new Uint8Array(hash.hash),
{ name: "AES-GCM", length: 256 },
false,
["decrypt"]
);
}Decrypted data: helloまとめ
PageCryptのように、「AES-GCM + PBKDF2 でHTMLを暗号化し、クライアント側で復号する」という流れを確認しました。
本家PageCrypt実装はPBKDF2のみですが、argon2にも差し替え可能であり、必要に応じて派生させられます。
静的ページで手軽にパスワード保護をしたいときに役立ちます。
(おまけ) 追加認証データ(AAD)
AES-GCMでは、「AAD(Additional Authenticated Data)」という領域に、暗号化するデータ本体以外のメタデータを載せることができます。
AADは、暗号の強さ自体には影響しませんが、通信の安全性には関係します。 例えば、プロトコルのバージョン番号や受信者のアドレスなどを AAD に含めないと、攻撃者が自由に書き換えることができてしまいます。 その結果、古い安全性の低いバージョンのプロトコルに変更されるなどのリスクが生じます。
ただ、GCM の動作を変えるような情報は、認証タグの検証より前にチェックしておく方が安全です8。
import os
from Crypto.Cipher import AES
key = b"0"*16
plaintext = b"hello"
aad = b"metadata123"
iv = os.urandom(12)
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
cipher.update(aad)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
cipher2 = AES.new(key, AES.MODE_GCM, nonce=iv)
try:
print(cipher2.decrypt_and_verify(ciphertext, tag))
except ValueError as e:
print(e)
cipher3 = AES.new(key, AES.MODE_GCM, nonce=iv)
cipher3.update(aad)
try:
print(cipher3.decrypt_and_verify(ciphertext, tag))
except ValueError as e:
print(e)s(おまけ) 画像の対応
HTML内の画像も暗号化したい場合、画像をbase64で埋め込む方法があります。
path = Path("aiueo.png")
data = base64.b64encode(path.read_bytes()).decode()
element = f"data:image/png;base64,{data}"
あとは <img src="data:image/png;base64,iVBORw0KGg..." /> という形でHTMLに埋め込めば、ひとつのHTMLファイルで完結します。
参考
12バイトの長さのIV(nonce)が、"NIST SP 800-38D"によって推奨されているようです。
WASM実装のようです。https://cdnjs.com/libraries/argon2-browser