はじめに
Webゲームにおいて「ボーナスチャレンジ」のような一時的なミニゲームを実装する場合、サーバー側でチャレンジの状態(正解・発行時刻・報酬額)を管理するのが一般的です。しかし、DBにチャレンジ状態を保存するとテーブル設計・有効期限管理・同時実行制御といった複雑さが増します。
この記事では、HMAC-SHA256署名を使ったステートレスなチャレンジ認証の設計と実装を解説します。実際にビットコインシミュレーター(bitcoin.ne.jp)のノンスチャレンジ機能で採用した方式です。
課題:サーバーに状態を持ちたくない
ノンスチャレンジは、マイニング時に3%の確率で発生するボーナスイベントです。
- サーバーがランダムな選択肢を4つ生成(1つが正解)
- クライアントが5秒以内に正解を選択
- 正解なら報酬が3倍になる
素朴に実装すると、チャレンジを発行するたびにDBに保存し、回答時に照合して削除する必要があります。
// 素朴な実装(DBに状態を持つ)
// 発行時: INSERT INTO nonce_challenges (id, correct_index, created_at, base_reward)
// 回答時: SELECT → 検証 → DELETE
// 問題: レコード肥大化、期限切れクリーンアップ、同時実行
これをHMAC署名で解決します。
設計:トークンに全情報を埋め込む
発想はJWTと同じです。検証に必要な情報をすべてトークンに含め、HMAC署名で改ざんを防ぎます。
トークン形式: {correctIndex}:{createdAt}:{baseReward}:{signature}
例: 2:1709712000000:0.00012345:a1b2c3d4e5f6...
| フィールド | 役割 |
|---|---|
| correctIndex | 正解の選択肢インデックス(0-3) |
| createdAt | チャレンジ発行時のタイムスタンプ(ms) |
| baseReward | ベース報酬額(BTC) |
| signature | 上記3フィールドのHMAC-SHA256署名 |
実装:Web Crypto APIによるHMAC署名
署名関数
async function hmacSign(data: string): Promise<string> {
const secret = process.env.CRON_SECRET;
if (!secret) throw new Error('Secret must be configured');
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign(
'HMAC', key, encoder.encode(data)
);
return Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
ポイント:
-
crypto.subtleはNode.js 15+とすべてのモダンブラウザで利用可能 - 鍵のインポートと署名は非同期(
awaitが必要) - 署名結果をhex文字列に変換して可搬性を確保
チャレンジ生成
async function generateNonceChallenge(
baseReward: number
) {
const correctIndex = Math.floor(Math.random() * 4);
const createdAt = Date.now();
// ターゲットプレフィックス(表示用)
const targetBytes = crypto.getRandomValues(new Uint8Array(3));
const targetPrefix = Array.from(targetBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 3);
// 4つの選択肢を生成
const options: string[] = [];
for (let i = 0; i < 4; i++) {
if (i === correctIndex) {
// 正解: ターゲットプレフィックスで始まるハッシュ
const suffix = crypto.randomUUID()
.replace(/-/g, '').slice(0, 9);
options.push(targetPrefix + suffix);
} else {
// 不正解: 異なるプレフィックス
let hash: string;
do {
hash = crypto.randomUUID()
.replace(/-/g, '').slice(0, 12);
} while (hash.startsWith(targetPrefix));
options.push(hash);
}
}
// ステートレストークン生成
const payload = `${correctIndex}:${createdAt}:${baseReward}`;
const signature = await hmacSign(payload);
const token = `${payload}:${signature}`;
return { token, targetPrefix, options, timeLimit: 5000 };
}
生成されたトークンには正解インデックスが平文で含まれていますが、クライアントにはトークンの中身を解析させません。トークンは不透明な文字列としてそのまま返送させます。
チャレンジ検証
async function claimNonceBonus(
token: string,
selectedIndex: number
) {
// 1. トークンをパース
const parts = token.split(':');
if (parts.length !== 4) {
return { success: false, error: 'Invalid token' };
}
const [correctStr, createdStr, rewardStr, signature] = parts;
const correctIndex = parseInt(correctStr, 10);
const createdAt = parseInt(createdStr, 10);
const baseReward = parseFloat(rewardStr);
// 2. HMAC署名を検証(改ざん検知)
const payload = `${correctStr}:${createdStr}:${rewardStr}`;
const expectedSig = await hmacSign(payload);
if (signature !== expectedSig) {
return { success: false, error: 'Invalid signature' };
}
// 3. 有効期限チェック(5秒 + 2秒の猶予)
const elapsed = Date.now() - createdAt;
if (elapsed > 7000) {
return { success: false, error: 'Challenge expired' };
}
// 4. 正解チェック
if (selectedIndex !== correctIndex) {
return { success: false, error: 'Wrong answer' };
}
// 5. ボーナス付与
const bonusReward = baseReward * 2; // 3倍 - 1倍(既付与分)
// ... DB更新処理 ...
return { success: true, bonusReward };
}
セキュリティ分析
攻撃パターンと対策
| 攻撃 | 対策 |
|---|---|
| トークン改ざん(正解インデックスの書き換え) | HMAC署名が不一致になり拒否 |
| トークン再利用(同じ正解を何度も送信) | createdAtによる時間制限で期限切れ |
| ブルートフォース(全選択肢を試す) | 5秒の時間制限 + 1リクエストで1回答のみ |
| 秘密鍵の推測 | SHA-256の計算量的安全性(2^128の耐性) |
なぜ正解が平文で含まれていても安全なのか
トークンはサーバー間通信のためのものです。フローは次のとおりです。
- サーバーがトークンを生成 → クライアントに送信(選択肢とともに)
- クライアントがユーザーの回答とトークンをサーバーに返送
- サーバーがトークンを検証
クライアントがトークンをパースして正解を知ることは技術的に可能ですが、それはDevToolsでAPIレスポンスを見るのと同じです。ゲームの性質上、完全な不正防止よりも「カジュアルプレイヤーには公平に見える」ことが重要であり、暗号化のオーバーヘッドを避けています。
もし厳密な秘匿が必要な場合は、正解インデックスの代わりにそのハッシュ値を使う方式に変更できます。
// より厳密なバリエーション
const payload = `${await hmacSign(String(correctIndex))}:${createdAt}:${baseReward}`;
ステートレス認証のメリット
- DBテーブルが不要: チャレンジ用のテーブル設計・マイグレーション・クリーンアップが不要
- スケーラビリティ: 任意のサーバーインスタンスで検証可能(共有状態なし)
- 原子性: チャレンジの発行と検証が独立しているため、同時実行の問題がない
- シンプルさ: 署名関数1つで完結。テスト・デバッグが容易
Web Crypto API vs. Node.js crypto
// Web Crypto API(エッジランタイム対応)
await crypto.subtle.sign('HMAC', key, data);
// Node.js crypto(従来の方式)
import { createHmac } from 'crypto';
createHmac('sha256', secret).update(data).digest('hex');
Web Crypto APIを選んだ理由:
- Next.js のServer ActionsはEdge Runtimeでも動作する可能性がある
-
crypto.subtleはWeb標準であり、ブラウザ・Node.js・Deno・Cloudflare Workersで共通 - 非同期APIのため、大量の署名処理でもイベントループをブロックしない
まとめ
HMAC署名によるステートレスなチャレンジ認証は、「DBに書かなくても安全に状態を持てる」パターンです。JWTと同じ原理ですが、ライブラリなしで20行程度で実装できるシンプルさが魅力です。
ゲーム内イベントに限らず、以下のようなケースにも応用できます。
- メール認証リンク(トークンにuser_id + expiry + signature)
- CSRFトークン(セッションIDの代わりにHMAC署名)
- API rate limitのクォータ証明
重要なのは、「何をトークンに含めるか」と「何を検証するか」を明確に設計することです。
ビットコインシミュレーター: https://bitcoin.ne.jp