Tech Deep Dive

Web Crypto API:JavaScriptでProof of Workを実装する

· 4 min read

毎日1億件を超えるスパムメッセージがお問い合わせフォームに到達しています。その大半は、攻撃者にとってコストゼロです。ボットは50ミリ秒以内にフォームを入力・送信でき、1台のマシンから毎分数千回の送信が可能です。この非対称性は苛烈です。スパムの送信はほぼ無料である一方、その対処にはサーバーリソース、ストレージ、帯域幅、そして人的リソースが必要になります。

もし、この方程式をひっくり返せるとしたらどうでしょうか? フォーム送信のたびに、サーバーが処理を受け付ける前にブラウザが数百ミリ秒のCPU時間を消費しなければならないとしたら?

これがクライアントサイドProof of Work(PoW)の基本的な考え方です。Bitcoinのコンセンサスメカニズムを支えたのと同じ概念を、はるかにシンプルな目的——スパムにコストをかけさせること——に応用したものです。

本チュートリアルでは、すべてのモダンブラウザに搭載されているブラウザネイティブの暗号化インターフェースであるWeb Crypto APIを使った実装を、ステップバイステップで解説します。


問題:スパムはタダ

従来のスパム対策は大きく二つに分かれます。

  1. CAPTCHA —— ユーザーに人間であることを証明させる。効果はあるが、UXに悪影響を与え、AIによる突破も進んでいる。
  2. サーバー側フィルタリング —— 送信されたデータを受信後に分析する。機能するが、サーバーはすべてのリクエストを受信・解析・評価しなければならない。

いずれのアプローチにも共通する欠陥があります。攻撃者はリクエスト送信に一切コストを払いません。防御のコストはすべてサーバー側(そしてユーザー側)にかかっています。

Proof of Workはコストモデルを変えます。 フォーム送信が受け付けられる前に、クライアントは計算パズルを解かなければなりません。人間がフォームを入力している間、パズルはバックグラウンドで解かれます。しかし毎秒数千件の送信を試みるボットにとって、パズルは壁となります。送信1件ごとに実際のCPUサイクルが必要になるのです。

計算はシンプルです。PoWチャレンジの解決に200ミリ秒かかるとすると、1台のボットが送信できるのは毎秒5件にとどまり、数千件から激減します。難易度を上げれば、大規模スパムの経済性は崩壊します。


Proof of Workの仕組み

紙ナプキンの裏にスケッチできるほどシンプルな概念です。

チャレンジ・レスポンスモデル

  1. サーバーがチャレンジを生成する: ランダムな文字列と難易度レベル(例:「先頭が4つのゼロで始まるハッシュを見つけよ」)。
  2. クライアントがチャレンジを受け取り、総当たり計算を開始する:チャレンジ文字列にカウンター(nonceと呼ばれる)を追加し、その組み合わせをハッシュ化して、結果が難易度要件を満たすかチェックする。
  3. クライアントが有効なnonceを見つけたら、フォームデータとnonceの両方をサーバーに送信する
  4. サーバーは1回のハッシュ演算で解を検証する。 検証はO(1)。解を求めるのはO(2^d)(d は必要な先頭ゼロビット数)。

これが重要な特性です:解を求めるのは高コスト、検証は低コスト。 サーバーは作業が完了したことを確認するのにほとんどリソースを消費しません。

なぜSHA-256なのか

SHA-256がPoWパズルの標準的な選択肢となっている理由はいくつかあります。

  • 衝突耐性 —— 特定の性質を持つハッシュを見つけるためのショートカットは知られていない。
  • 決定論的 —— 同じ入力は常に同じ出力を生成するため、サーバーは再計算により検証できる。
  • 速すぎず、遅すぎず —— 1回の計算はミリ秒で完了する程度に高速だが、数百万回のハッシュを総当たりするには実際の時間がかかる程度に低速。
  • ネイティブサポート —— Web Crypto APIがすべてのモダンブラウザでハードウェアアクセラレーション付きのSHA-256を提供。

技術的詳解:Web Crypto API

Web Crypto APIwindow.crypto.subtle)は、ブラウザに組み込まれた低レベルの暗号化インターフェースです。CryptoJSやforgeのようなライブラリとは異なり、ネイティブコードで動作し、多くの場合OpenSSLやプラットフォーム固有のハードウェアアクセラレーションによってサポートされています。ポリフィルではなく、本物の暗号化エンジンです。

基本:文字列のハッシュ化

ブラウザでSHA-256ハッシュを計算する方法はこちらです。

async function sha256(message) {
  // 文字列をUint8Arrayにエンコード
  const msgBuffer = new TextEncoder().encode(message);

  // メッセージをハッシュ化
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);

  // ArrayBufferを16進文字列に変換
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashHex = hashArray
    .map(byte => byte.toString(16).padStart(2, '0'))
    .join('');

  return hashHex;
}

注意すべき点がいくつかあります。

  • crypto.subtle.digest()ArrayBuffer に解決される Promise を返します。文字列ではないため、変換が必要です。
  • このAPIはセキュアコンテキスト(HTTPSまたはlocalhost)でのみ動作します。平文HTTPのサイトでは機能しません。
  • この操作は設計上非同期です。ブラウザは内部的に計算を別スレッドにオフロードできるため、単一のハッシュ計算ではメインスレッドをブロックしません。ただし、PoWのようにループで数千回のハッシュを計算する必要がある場合は、Web Workerに処理を移すべきです(後述)。

大量のハッシュ計算:PoWループ

チャレンジ文字列と難易度(必要な先頭16進ゼロの数)が与えられたとき、条件を満たすnonceを見つける完全なPoWソルバーはこちらです。

async function solveProofOfWork(challenge, difficulty) {
  const prefix = '0'.repeat(difficulty);
  let nonce = 0;

  while (true) {
    const input = challenge + ':' + nonce;
    const hash = await sha256(input);

    if (hash.startsWith(prefix)) {
      return { nonce, hash };
    }

    nonce++;
  }
}

難易度スケーリング: 16進ゼロが1つ増えるごとに、平均作業量は16倍になります。難易度4(ハッシュが 0000 で始まる)には平均約65,536回の試行が必要です。難易度5では約1,048,576回です。コストは自由にコントロールできます。

難易度(16進ゼロの数) 平均試行回数 おおよその所要時間(モダンブラウザ)
3 約4,096 約50ms
4 約65,536 約300ms
5 約1,048,576 約4秒
6 約16,777,216 約60秒

フォームスパム対策としては、難易度4が一般的にスイートスポットです。ユーザーにとっては知覚できないレベル(パズルはまだ入力中にバックグラウンドで解かれる)ですが、大規模送信を試みるボットにとっては致命的です。


ソリューション:完全な実装

エンドツーエンドで動作するシステムを構築しましょう。ページロード時にサーバーがチャレンジを発行し、クライアントがバックグラウンドで解き、ユーザーがフォームを送信するときにソリューションがリクエストに添付されます。

ステップ1:PoW Worker

ハッシュループをメインスレッドで実行するとUIがフリーズします。代わりにWeb Workerを使いましょう。

// pow-worker.js
self.addEventListener('message', async (e) => {
  const { challenge, difficulty } = e.data;
  const prefix = '0'.repeat(difficulty);
  let nonce = 0;

  while (true) {
    const input = challenge + ':' + nonce;
    const msgBuffer = new TextEncoder().encode(input);
    const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    if (hashHex.startsWith(prefix)) {
      self.postMessage({ nonce, hash: hashHex });
      return;
    }

    nonce++;

    // 1000回の反復ごとに制御を譲渡してWorkerのレスポンシブ性を維持
    if (nonce % 1000 === 0) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
});

ステップ2:クライアントサイドの統合

// main.js
class ProofOfWork {
  constructor(challenge, difficulty) {
    this.challenge = challenge;
    this.difficulty = difficulty;
    this.solution = null;
    this.solved = false;
  }

  start() {
    return new Promise((resolve, reject) => {
      const worker = new Worker('/pow-worker.js');

      worker.addEventListener('message', (e) => {
        this.solution = e.data;
        this.solved = true;
        worker.terminate();
        resolve(e.data);
      });

      worker.addEventListener('error', (e) => {
        worker.terminate();
        reject(e);
      });

      worker.postMessage({
        challenge: this.challenge,
        difficulty: this.difficulty,
      });
    });
  }
}

// 使い方:ページロード時に解決を開始
document.addEventListener('DOMContentLoaded', async () => {
  // これらの値はサーバーから取得(例:data属性に埋め込み、またはAPI経由で取得)
  const challenge = document.querySelector('[data-pow-challenge]')?.dataset.powChallenge;
  const difficulty = parseInt(
    document.querySelector('[data-pow-difficulty]')?.dataset.powDifficulty || '4',
    10
  );

  if (!challenge) return;

  const pow = new ProofOfWork(challenge, difficulty);
  const startTime = performance.now();

  pow.start().then(({ nonce, hash }) => {
    const elapsed = (performance.now() - startTime).toFixed(0);
    console.log(`PoW solved in ${elapsed}ms (nonce: ${nonce}, hash: ${hash})`);

    // ソリューションをフォームに注入
    const form = document.querySelector('form');
    if (form) {
      const nonceInput = document.createElement('input');
      nonceInput.type = 'hidden';
      nonceInput.name = 'pow_nonce';
      nonceInput.value = nonce;
      form.appendChild(nonceInput);

      const challengeInput = document.createElement('input');
      challengeInput.type = 'hidden';
      challengeInput.name = 'pow_challenge';
      challengeInput.value = challenge;
      form.appendChild(challengeInput);
    }
  });

  // パズルが解けるまでフォーム送信をブロック
  const form = document.querySelector('form');
  if (form) {
    form.addEventListener('submit', (e) => {
      if (!pow.solved) {
        e.preventDefault();
        // オプション:「確認中...」メッセージを短時間表示
        pow.start().then(() => form.submit());
      }
    });
  }
});

ステップ3:サーバー側の検証(PHPの例)

検証は1回のハッシュ演算です。最小限のPHP実装はこちらです。

function verify_pow(string $challenge, int $nonce, int $difficulty): bool {
    $input = $challenge . ':' . $nonce;
    $hash  = hash('sha256', $input);
    $prefix = str_repeat('0', $difficulty);

    return str_starts_with($hash, $prefix);
}

// フォームハンドラ内:
$challenge  = $_POST['pow_challenge'] ?? '';
$nonce      = (int) ($_POST['pow_nonce'] ?? 0);
$difficulty = 4;

// ステップ1: チャレンジがこのサーバーで発行されたものか検証(例:HMACチェック)
// ステップ2: PoWソリューションを検証
if (!verify_pow($challenge, $nonce, $difficulty)) {
    http_response_code(403);
    exit('Invalid proof of work.');
}

重要なセキュリティ上の注意: 上記のコードはわかりやすさのために簡略化しています。本番環境では以下が必要です。

  1. チャレンジにサーバー側でHMAC署名を付与する。 クライアントが任意のチャレンジを偽造できないようにするため。
  2. チャレンジにタイムスタンプを含め、古すぎるソリューション(例:5分以上前)を拒否する。
  3. リプレイ攻撃を防止する。 使用済みのチャレンジ/nonceを保存し、重複を拒否する。

本番環境で使えるチャレンジは次のようになるでしょう。

// 署名付きチャレンジの生成
$timestamp = time();
$random    = bin2hex(random_bytes(16));
$payload   = $timestamp . ':' . $random;
$signature = hash_hmac('sha256', $payload, $secret_key);
$challenge = $payload . ':' . $signature;

サーバーは署名を検証し、タイムスタンプを確認し、PoWソリューションを検証できます——すべてデータベースアクセスなしで。完全にステートレスです。


パフォーマンスに関する考慮事項

ハッシュのバッチ処理による高速化

crypto.subtle.digest() の呼び出しには、非同期境界に起因するオーバーヘッドがあります。最大スループットを得るには、WebAssemblyによるSHA-256実装でハッシュをバッチ処理することもできます。hash-wasm のようなライブラリは、Promiseのオーバーヘッドを回避するため、タイトなループではWeb Crypto APIの約3~5倍の速度でSHA-256を計算できます。

ただし、ほとんどのスパム対策用途ではWeb Crypto APIで十分です。目的は高速であることではなく、十分に遅いことなのです。

モバイルデバイスへの影響

低性能なモバイルデバイスでは、難易度4のチャレンジに300msではなく500ms~1秒かかる場合があります。それでもフォーム送信の許容範囲内です。より細かい制御が必要な場合は、適応的難易度を検討してください。サーバーがモバイルと特定されたクライアント(User-AgentまたはClient Hints経由)にはより簡単なチャレンジを発行する方法や、最初の100回の反復でクライアントのハッシュレートを計測して動的に調整する方法があります。

ブラウザ互換性

Web Crypto APIはすべてのモダンブラウザでサポートされています。

  • Chrome 37+
  • Firefox 34+
  • Safari 11+
  • Edge 12+

サポートされていないブラウザの約0.5%のユーザーに対しては、グレースフルにフォールバックしてください。フォームをブロックせず、PoWをスキップしてサーバー側の防御に頼ります。

if (!window.crypto?.subtle) {
  console.warn('Web Crypto API not available. Skipping PoW.');
  // PoWなしでフォーム送信を許可;サーバー側でより厳格なチェックを適用
}

PoW vs. 他のスパム対策手法

手法 ボットのコスト ユーザーの負担 プライバシー サーバー負荷
reCAPTCHA v2
reCAPTCHA v3 なし
ハニーポットフィールド なし
レートリミティング
クライアントPoW なし 非常に低

PoWは万能薬ではありません。GPUファームを持つ高度な攻撃者はPoWチャレンジを素早く解くことができます。しかしスパムはボリュームビジネスであり、ほとんどのスパム運営は最小限のマージンで動いています。たとえ控えめな計算コストでも、数百万件の送信に適用されれば、経済性は劇的に変わります。

最も強力な防御は多層セキュリティです。PoWとハニーポット、行動分析、サーバー側バリデーションを組み合わせます。各レイヤーが他のレイヤーが見逃すものを捕捉します。


実践で活用するには

WordPressでContact Form 7を使用していて、インフラを自分で構築せずにこの種の多層防御を実現したい場合、Samurai Honeypot for Forms がこれらのテクニック——動的ハニーポットやクライアントサイドの計算チャレンジを含む——を設定不要の単一プラグインにまとめています。すべて自社サーバー上で動作し、外部APIコールは一切なく、GDPRフレンドリーでサードパーティへの依存を排除しています。

プラグインを使うか自前で構築するかに関わらず、原則は同じです:スパムにコストをかけさせること。 Proof of Workはそれを実現する最もクリーンな方法の一つです。


参考資料

  • MDN: SubtleCrypto.digest() —— Web Crypto APIのハッシュ関数に関する公式ドキュメント。
  • Hashcash —— 1997年にAdam Backが考案した、メールのスパム対策向けオリジナルPoWシステム。
  • Web Workers API —— ブラウザでバックグラウンドスレッドを実行するためのMDNリファレンス。
すべてのコラム