Skip to content

ハウツー: ワンタイムシークレット API & ATK-01〜12 クラッカー攻撃テスト

NENE2 フィールドトライアル 184 — クラッカー攻撃テストサイクル(ATK-01〜12)。 トークン自体が認証情報。アトミックな消費が競合状態を防ぎます。


このトライアルが実証すること

ワンタイムシークレットは 1 回だけ読める暗号化メッセージを保存します。 最初の読み取りが成功すると、シークレットは永久に消費されます。

セキュリティ要件:

  1. 256 ビットトークンエントロピー — ブルートフォースは計算上不可能
  2. アトミックな消費UPDATE WHERE consumed=0 でダブルリードの競合状態を防ぐ
  3. IDOR 防止 — 削除にはトークンとユーザーオーナーシップの両方が必要
  4. マス代入ブロック — consumed/token/created_at はサーバー側のみ
  5. 型安全性 — V::str() / V::userId() / V::queryInt() が非文字列入力を拒否

API

メソッドパス認証説明
POST/secretsX-User-Idワンタイムシークレットを作成する
GET/secretsX-User-Id自分のシークレット一覧(メタデータのみ、メッセージなし)
GET/secrets/{token}読み取り + 消費(トークン自体が認証情報)
DELETE/secrets/{token}X-User-Id読み取り前にキャンセル(オーナーのみ)

ATK-01〜12 結果

ID攻撃ベクター防御結果
ATK-01トークンへの SQL インジェクションPDO パラメーター化クエリ✅ PASS
ATK-02IDOR クロスユーザー削除WHERE token=? AND user_id=?✅ PASS
ATK-03マス代入(body に consumed=1サーバー側フィールドのみ✅ PASS
ATK-04メッセージへの XSS ペイロードJSON API — HTML レンダリングなし✅ PASS
ATK-05ダブルエンコード / 不正なトークン/^[0-9a-f]{64}$/ フォーマットチェック✅ PASS
ATK-06読み取りでの認証バイパストークン自体が認証情報 — 設計による✅ PASS
ATK-07message/password を非文字列で送信V::str()is_string() を強制✅ PASS
ATK-08limit/offset に 20 桁の数値オーバーフローV::queryInt() の strlen > 18 ガード✅ PASS
ATK-09limit パラメーターへの ReDoSctype_digit() — O(n)、バックトラッキングなし✅ PASS
ATK-10トークンへのブルートフォースrandom_bytes(32) = 2^256 エントロピー✅ PASS
ATK-11競合状態のダブルリードUPDATE WHERE consumed=0 + rowCount チェック✅ PASS
ATK-12X-User-Id へのヘッダーインジェクションV::userId()ctype_digit() を強制✅ PASS

12/12: PASS


コアパターン: アトミックな消費

重要なセキュリティ不変条件 — シークレットは 1 回だけ読めます:

php
// SecretRepository::consumeByToken()

// ステップ 1: シークレットを取得する(通常の SELECT — ガードではない)
$row = $pdo->prepare('SELECT * FROM secrets WHERE token = :token');
$row->execute(['token' => $token]);
$secret = $row->fetch(PDO::FETCH_ASSOC);

// ステップ 2: consumed フラグをチェックする(一般的なケースの早期終了)
if ($secret['consumed']) return null;

// ステップ 3: アトミックな UPDATE — これが実際のガード
$update = $pdo->prepare(
    'UPDATE secrets SET consumed = 1 WHERE token = :token AND consumed = 0'
);
$update->execute(['token' => $token]);

// ステップ 4: rowCount() === 0 は別の読み取りが競争に勝ったことを意味する
if ($update->rowCount() === 0) {
    return null; // SELECT と この UPDATE の間に誰かが消費した
}

// ステップ 5: 勝利 — シークレットを返す
return Secret::fromRow($secret);

なぜこれが機能するか: SQLite とほとんどの RDBMS は UPDATE WHERE consumed=0 がアトミックであることを保証します。 並行ライターの 1 つだけが consumed を 0→1 に変更できます。敗者の rowCount() は 0 を返します。


トークン生成

php
$token = bin2hex(random_bytes(32)); // 64 hex 文字 = 32 バイト = 256 ビット
  • random_bytes() は OS の CSPRNG(/dev/urandom と同等)を使用します
  • 10^12 回/秒のレートで 2^256 トークンをブルートフォースするには約 10^60 年かかります
  • トークンは DB で一意です(UNIQUE 制約)

トークンフォーマットバリデーション

php
private const TOKEN_PATTERN = '/^[0-9a-f]{64}$/';

// 拒否: 大文字 hex、パストラバーサル ../../、URL エンコード、整数、空文字列
if (!preg_match(self::TOKEN_PATTERN, $rawToken)) {
    return $this->responseFactory->create(['error' => 'Secret not found.'], 404);
}

IDOR 防止(ATK-02)

php
// DELETE にはトークンのオーナーシップと user_id の一致の両方が必要
$stmt = $pdo->prepare(
    'DELETE FROM secrets WHERE token = :token AND user_id = :user_id AND consumed = 0'
);
$stmt->execute(['token' => $token, 'user_id' => $userId]);

// 理由に関係なく 404 を返す — 列挙 oracle を避ける
return $stmt->rowCount() > 0;

マス代入防止(ATK-03)

サーバー側フィールドはリクエストボディから絶対に読み取りません:

php
// POST /secrets ハンドラー — ボディから受け付けるのは message、password、expires_at のみ
$token        = bin2hex(random_bytes(32));  // サーバー生成
$consumed     = 0;                          // 常に未消費で開始
$createdAt    = (new DateTimeImmutable())->format(DateTimeInterface::ATOM); // サーバー時刻
$passwordHash = $password !== null ? hash('sha256', $password) : null;     // サーバー側でハッシュ化

// body['consumed']、body['token']、body['user_id']、body['created_at'] はサイレントに無視される

V.php バリデーションチェーン

php
// ATK-07: message は文字列でなければならない(int、bool、null、array を拒否)
$message = V::str($body['message'] ?? null, 10000);

// ATK-12: X-User-Id は ctype_digit + 正の値 + 最大 18 文字でなければならない
$userId = V::userId($request->getHeaderLine('X-User-Id'));

// ATK-08/09: limit は数値、最大 18 桁、範囲 1〜100 でなければならない
$limit = V::queryInt($params, 'limit', 1, 100, 20);

オプションのパスワード保護

php
// 保存: SHA-256 ハッシュのみ(平文ではない)
$passwordHash = $password !== null ? hash('sha256', $password) : null;

// 検証: 定数時間比較(タイミングセーフ)
if (!hash_equals($secret->passwordHash, hash('sha256', $submittedPassword))) {
    return null; // パスワード不正 → サイレント 404(oracle なし)
}

注意: パスワード不正は 404 を返します(403 ではなく)。oracle 攻撃を防ぐためです。 シークレットはパスワード不正では消費されません — 正しいパスワードだけが消費します。


メタデータ一覧(メッセージ漏洩なし)

php
// GET /secrets — メタデータのみを返し、メッセージは絶対に返さない
private function secretToMetadata(Secret $secret): array
{
    return [
        'token'        => $secret->token,
        'has_password' => $secret->passwordHash !== null,
        'consumed'     => $secret->consumed,
        'expires_at'   => $secret->expiresAt,
        'created_at'   => $secret->createdAt,
        // 'message' は意図的に省略
    ];
}

テスト結果

85 tests / 209 assertions — all PASS
PHPStan level 8 — no errors
PHP CS Fixer — clean

主要ポイント

パターンルール
アトミックな消費UPDATE WHERE consumed=0 + rowCount() チェック — SELECT してから UPDATE ではない
トークンエントロピー最低 random_bytes(32)(256 ビット)— 連番 ID は使わない
トークンフォーマット両端でアンカーされた許可リスト正規表現(/^[0-9a-f]{64}$/
IDORすべての書き込み操作は token AND user_id でスコープする
マス代入トークン、consumed、created_at — サーバー側のみ、ボディからは絶対に取得しない
パスワードタイミング定数時間比較に hash_equals() を使う
パスワード不正404、403 ではない — シークレットの存在を確認しない
メタデータ一覧一覧エンドポイントからメッセージを省略 — 消費時のみ読み取り

完全な例: ../NENE2-FT/onetimelog/

MIT ライセンスの下で公開されています。