PageCryptベースのHTML暗号化を確認する

2025-02-23

HTMLページをパスワード保護したい場合、Basic認証やDigest認証などサーバー設定が必要な手法を使うのが一般的です。 しかし、"静的ホスティング上で完結させたい"といったシーンでは、PageCrypt[1]のようにクライアント側で復号する仕組みが有用です。

PageCryptは、HTMLファイルを事前に暗号化し、JavaScriptで復号化することで、サーバーの追加設定を不要にしています。

本記事では、PythonとJavaScriptでPageCrypt相当の処理を再現しながら、AES-GCM暗号とPBKDF2(あるいはargon2)によるキー導出を解説します。

Pythonの暗号化ライブラリには pycryptodome[2] を、JavaScriptの復号には Web Crypto API を使用しています。

全体の流れ

以下のようなフローで、パスワードと平文(HTML)を用いて、AES-GCMで暗号化します。

PBKDF2+AES-GCM

  1. 平文とパスワードを用意する
  2. PBKDF2を用いてパスワードから鍵(key)を生成
  3. 生成した鍵を利用して、AES-BCMで暗号化を行う
  4. salt、IV[3]、暗号文と認証タグ(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: hello

argon2

オリジナルのPageCryptにはありませんが、argon2を使ったキー導出に差し替えることもできます。

2015年のパスワードハッシュコンペティション[4]の優勝者であるArgon2は、PBKDF2の代替として利用可能です。 たとえば、BitwardenだとArgon2idを実装しています[5]

以下の例でも2idを利用しています。

Pythonではargon2-cffi[6]、JavaScriptではargon2-browser[7]を利用します。

基本的な部分は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ファイルで完結します。

参考

3

12バイトの長さのIV(nonce)が、"NIST SP 800-38D"によって推奨されているようです。

7

WASM実装のようです。{https://cdnjs.com/libraries/argon2-browser}