こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第44回です。
前回(第43回)では、難読化カウンターパッキングを導入し、連番推測を防ぎながら「過去の正規IDであれば空部屋を再開できる(リオープン)」という便利な仕組みを作りました。
しかし、この機能を公開した直後、セキュリティ検証を行っていた開発者が青ざめるような 重大なセキュリティホール(脆弱性) を発見しました。
「パスワードを設定して鍵をかけていた部屋でも、 全員が退席して部屋が空になった瞬間に、パスワードを知らない部外者が入って部屋を乗っ取れてしまうのでは!? 」
本稿では、サーバーレスP2Pの「空部屋再開」に潜んでいた乗っ取りリスクの全貌と、最小限のメタデータ永続化(ポリシー・パスワードハッシュ)によって合言葉を死守したセキュリティ設計を解説します!
1. 発見された脆弱性:「もぬけの殻」の部屋乗っ取り
P2Pチャットにおいて、参加者全員がブラウザを閉じると、シグナリングサーバーのインメモリマップからルーム情報が消去されます。
このとき、前回の「空部屋再開機能」の素朴な実装では、以下のような致命的な乗っ取りシナリオが成立してしまっていました。
部屋が空になった瞬間、部屋の「鍵(パスワードポリシー)」の設定がメモリとともに吹き飛んでしまい、最初に入った人がパスワードなしで新しい主(ホスト)になれてしまっていたのです。
2. 解決策:ポリシー&パスワードハッシュの軽量レジストリ
この問題を解決するため、私たちは 「会話ログや個人情報は一切保存しないが、部屋の入室ポリシーとパスワードのSHA-256ハッシュだけを最小限永続化する」 という境界線を引きました。
レジストリテーブルの設計
プライバシーを極限まで保護するため、テーブルに保存するのは以下のメタデータのみに絞り込みました。
CREATE TABLE IF NOT EXISTS p2p_room_registry (
room_id TEXT PRIMARY KEY,
counter INTEGER NOT NULL,
join_policy TEXT NOT NULL, -- 'open' | 'invite' | 'password'
pass_hash TEXT, -- パスワードのSHA-256ハッシュ(平文は保存しない)
persist_history TEXT NOT NULL, -- 'allow' | 'ephemeral'
created_at INTEGER NOT NULL
);
- チャット本文 : 1文字も保存しない(P2P DataChannel完結)。
- IPアドレスや参加者リスト : 一切記録しない。
- パスワード : 生の文字列は持たず、SHA-256ハッシュのみを保持。
3. 実装の全貌:空部屋再開時の厳格なカギ照合
(1) 部屋作成時のポリシー登録
ホストが部屋を建てた際、カウンターの発行と同時にレジストリへポリシーを保存します。
// 部屋作成時
function registerRoomMetadata(roomId, counter, payload) {
const policy = payload.policy || 'open';
const persistPolicy = payload.persistPolicy || 'allow';
// パスワードが設定されている場合はSHA-256ハッシュを計算
let passHash = null;
if (policy === 'password' && payload.passcode) {
passHash = crypto.createHash('sha256').update(String(payload.passcode)).digest('hex');
}
db.run(`
INSERT INTO p2p_room_registry (room_id, counter, join_policy, pass_hash, persist_history, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, [roomId, counter, policy, passHash, persistPolicy, Date.now()]);
}
(2) 参加・再開時のカギ照合ハンドラ
部屋がメモリ上に存在しない(空部屋)場合でも、レジストリからメタデータを取得してパスワードを検証します。
// 参加リクエスト受信時
if (action === 'join' && !isCreate) {
const meta = getRoomMetadataFromDB(roomId);
if (!meta) {
return res.json({ success: false, error: 'INVALID_ROOM_ID', message: '指定されたルームIDは存在しません。' });
}
// ★パスワード付き部屋の検証
if (meta.join_policy === 'password') {
const clientPassHash = payload.passcode ?
crypto.createHash('sha256').update(String(payload.passcode)).digest('hex') : null;
// パスワードが未入力、またはハッシュが一致しない場合はブロック!
if (!clientPassHash || clientPassHash !== meta.pass_hash) {
return res.json({
success: false,
error: 'INVALID_PASSWORD',
message: 'パスコードが正しくありません。'
});
}
}
// 検証を通過した場合のみ、空部屋を再開または既存部屋へ合流
let activeRoom = rooms.get(roomId);
if (!activeRoom) {
// 元のポリシー(パスワード制・履歴保存方針)を完全維持したまま再開!
activeRoom = recreateRoomFromMeta(meta);
rooms.set(roomId, activeRoom);
}
// ユーザーを部屋へ追加...
}
4. 自動テストによる脆弱性再現&ブロック検証
テストスクリプトを実行し、脆弱性が確実に塞がれたことを確認しました。
// test_room_password_persistence.js の検証内容
async function runTests() {
// 1. パスワード "secret123" で部屋を作成
const roomId = await createSecureRoom('secret123');
// 2. 全員退席させてメモリから部屋を消滅させる(空部屋化)
drainRoom(roomId);
// 3. パスワードなし・誤ったパスワードで再開を試みる(乗っ取り攻撃シミュレーション)
const hackAttempt = await joinRoom(roomId, 'wrong_pass');
assert.strictEqual(hackAttempt.error, 'INVALID_PASSWORD', '誤ったパスワードは弾かれること');
console.log('✅ 乗っ取り攻撃が確実にブロックされました!');
// 4. 正しいパスワード "secret123" で再開を試みる
const legitJoin = await joinRoom(roomId, 'secret123');
assert.strictEqual(legitJoin.success, true, '正しいパスワードで空部屋が正常再開されること');
assert.strictEqual(legitJoin.isHost, true, '最初の再訪者がホストに就任すること');
console.log('✅ 正当なメンバーによる安全な空部屋再開に成功しました!');
}
5. まとめと次回予告
空部屋再開機能がもたらした「乗っ取りリスク」を、軽量レジストリテーブルによるポリシー・パスワードハッシュの永続化によって完全に封じ込めました。
これにより、合言葉を知っている仲間たちだけが、翌日でも同じURLで安全に秘密基地を再開できるようになりました。
しかし、この完成形に満足しかけた私たちの心に、ある 「エンジニアとしての純粋な狂気(探求心)」 が芽生えました。
「ちょっと待てよ……。ポリシーやカウンターを検証するために、 なぜサーバー側にSQLiteテーブルを持たなければいけないんだ? 」
「もし、URLトークンの中に暗号化されたポリシーやパスワード情報を全部詰め込んでしまえば、 サーバーのDBすら 100% 撤廃できるんじゃないか!? 」
次回は、P2P開発録のクライマックスへ。
次回は、 【第045回】DBサーバー不要の奇跡!暗号化パスワードとカウンターを内包した「自己完結型ステートレスID」 をお届けします。お楽しみに!
本番サービス : https://2ch.biz/