※本実装はあくまで簡易的な難読化であり、暗号学的な改ざん防止・推測防止にはHMAC等の鍵付き署名が必要です
こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第43回です。
前回(第42回)では、データベースに過去ログを一切持たないまま、たった1つの整数カウンターを使って「出鱈目なID」と「過去に終了した部屋」を高精度に見分けるアルゴリズムを導入しました。
しかし、その実装直後、セキュリティ監査を行っていたエンジニアから重大なリスクが提起されました。
「ルームIDに
room_105_...のように連番が剥き出しになっているのは非常にまずい!」
「攻撃者にサービスの利用規模が筒抜けになるだけでなく、前後の番号(104や103)を推測して 他人のプライベートチャットへ侵入・覗き見 される危険がある!」
さらにユーザーからも、「昨晩盛り上がった部屋で『また明日このURLに集合ね』と約束したのに、朝起きたら部屋が終了していて再入室できない!」という悲鳴が上がりました。
本稿では、連番カウンターを暗号数学的にランダム文字列の中へ埋め込む 「難読化カウンターパッキング」 と、 「過去の正規IDであれば空部屋を安全に再開できるリオープン機能」 の実装を徹底解説します!
1. 露出した連番IDがもたらす「3つの脅威」
第42回で採用した room_<カウンター番号>_<ランダム文字列>(例: room_42_a8b9c0d1)は、サーバーにとっては判定しやすい形式でした。しかし、セキュリティの観点では以下の脆弱性を孕んでいました。
-
ビジネス規模・開設ペースの漏洩 :
- 悪意ある第三者が1時間に1回部屋を作るだけで、「この1時間で何個の部屋が作られたか」「累計で何人が使っているか」という機密データが完全に丸裸になります。
-
前後番号への推測攻撃(Enumeration Attack) :
- 自分のIDが
room_105_...だった場合、攻撃者は「直前に作られたroom_104やroom_103が現在アクティブなのではないか?」と推測し、総当たりで侵入を試みる動機を与えてしまいます。
- 自分のIDが
-
「翌日の再集合」ができない不便さ :
- 過去の番号をすべて
ROOM_CLOSEDで門前払いしてしまうと、全員がログアウトした後に同じURLで再会することが物理的に不可能になっていました。
- 過去の番号をすべて
2. 解決策:難読化カウンターパッキングの数学
連番の判定能力を100%維持しながら、外部からは完全なランダム文字列に見せるため、私たちは 「ビット演算とBase36変換による難読化エンコード」 を設計しました。
$$\text{生成フロー}: \quad N \xrightarrow{\text{ソルトXOR + チェックサム付与}} \text{PackedBit} \xrightarrow{\text{Base36エンコード}} \texttt{room_7k9a2f4m8x}$$
パッキングの構造(32bit整数内)
- カウンタ番号 $N$ : 1〜1,000,000(20bit)
- 固定ソルトとのXOR : 単純な増加傾向を打ち消し、乱数のように散乱させる。
- チェックサム(CRC / ハッシュの先頭数ビット) : 改ざんやランダムな文字列入力(偽造ID)をデコード時に即座に弾くための署名。
外見は room_ に続く英小文字・数字の10文字程度になり、ユーザーから見ても第三者から見ても、単なる推測不能なハッシュ値にしか見えません。
3. 実装の全貌:エンコーダーとデコーダー
[!WARNING]
【重要】暗号数学的な落とし穴:単なるXOR難読化を「安全」と過信してはいけない!
本稿で紹介する実装は、「連番が平文で露出している状態」を防ぐために開発初期に導入した難読化アプローチです。
しかし、XOR演算は数学的に完全な可逆変換($A \oplus B = C \iff C \oplus B = A$) です。
ソルト値が固定である場合、攻撃者が有効な部屋IDを1つ入手してデコードすると、カウンター番号が完全に復元されてしまいます。その結果、次の番号($N+1$)から偽造IDを作成してチェックサムを突破する 連番推測攻撃(Enumeration Attack) が理論上可能になります。つゔぁいちゃんねるでは、このセキュリティ限界を解決するため、後のバージョンにおいて連番カウンターを全廃し、「完全ランダムNonce(CSPRNG)+ サーバー秘密鍵HMAC署名付きステートレストークン」 へと根本進化させました。
本稿のコードは「泥臭い試行錯誤の歴史」としてご覧いただき、本番プロダクトでは単なるXOR難読化ではなく、必ずHMAC等の暗号学的署名を採用してください。
(1) サーバー側:難読化IDの発行
const OBFUSCATION_SALT = 0x2a7b; // ※解説用のダミー値 (本番では秘密鍵HMACへ移行)
/**
* 連番カウンターを難読化してIDを生成 (初期実装)
*/
function encodeObfuscatedRoomId(counter) {
// 1. カウンター値とソルトをXOR
const masked = (counter ^ OBFUSCATION_SALT) >>> 0;
// 2. 簡易チェックサム(下位8bit)を付与
const checksum = ((counter * 31 + 17) & 0xff);
// 3. マスク値とチェックサムを結合してBigInt/Hex化
const combined = (BigInt(masked) << 8n) | BigInt(checksum);
// 4. Base36(0-9, a-z)に変換し、末尾にランダムソルトを添える
const encodedStr = combined.toString(36);
const randomPadding = crypto.randomBytes(2).toString('hex'); // 4文字の乱数
return `room_${encodedStr}${randomPadding}`;
}
(2) サーバー側:デコードと検証ロジック
参加者がIDを送ってきた際、サーバーは秘密のソルトを使って逆算し、カウンター番号とチェックサムの整合性を検証します。
/**
* ルームIDからカウンターを復元し、正当性を検証
*/
function decodeAndValidateRoomId(roomId) {
if (!roomId || !roomId.startsWith('room_')) return null;
const raw = roomId.slice(5); // "room_" を除去
if (raw.length < 6) return null;
// 末尾のランダム4文字を除去
const encodedStr = raw.slice(0, -4);
try {
const combined = BigInt(parseInt(encodedStr, 36));
const checksum = Number(combined & 0xffn);
const masked = Number((combined >> 8n) & 0xffffffffn);
// XORで元のカウンターを復元
const counter = (masked ^ OBFUSCATION_SALT) >>> 0;
// チェックサムの照合
const expectedChecksum = ((counter * 31 + 17) & 0xff);
if (checksum !== expectedChecksum) {
return null; // 偽造またはタイポ
}
return counter;
} catch (e) {
return null;
}
}
4. 空部屋の安全な再開(Reopen):翌日の再集合を可能に
難読化によって他人のID推測が不可能になったことで、私たちは 「過去に正規発行されたIDであれば、現在誰もいなくても部屋を再開できる」 という柔軟なライフサイクルを導入しました。
この設計のメリット
-
偽造・出鱈目なID : デコードに失敗するか、未発行の未来番号になるため、
INVALID_ROOM_IDで 100% 確実に門前払い される。 - 過去の正規ID : 全員が退出した後でも、正規の招待URLを持っていればいつでも同じ部屋を再開できる。最初に入室した人が自動的にホストとなり、端末内の過去ログを持ち寄って会話がシームレスに再開する。
5. 自動テストによる検証結果
自動テストを実行し、偽造防御と再開機能の安全性を確認しました。
| テストケース | 入力ID | 期待される判定 | 実行結果 |
|---|---|---|---|
| 完全な出鱈目入力 | room_random_fake_123 |
INVALID_ROOM_ID(チェックサム不一致で即死) |
✅ PASS |
| 未発行の未来ID | カウンターが未来の値のID |
INVALID_ROOM_ID(未発行ブロック) |
✅ PASS |
| 正規IDでの新規作成 | 発行された難読化ID | 正常入室・ホスト権限獲得 | ✅ PASS |
| 全員退席後の再入室 | 直前の正規難読化ID | 部屋が自動再開され、新ホストとして入室成功! | ✅ PASS |
6. まとめと次回予告
難読化カウンターパッキングにより、以下の成果を同時に成し遂げました:
- 完全な推測防止 : 外部からはランダム文字列にしか見えず、連番推測や利用規模の漏洩を完全に根絶。
- ゼロストレージの維持 : DBに履歴テーブルを作らないまま、正規IDか出鱈目IDかを瞬時に検証。
- 実用的な再開機能 : 「同じURLで明日また集合」というユーザーの自然な行動をサポート。
しかし、空部屋の再開ができるようになったことで、次に浮上したのが 「パスワード付き部屋の乗っ取りリスク」 でした。
「部屋主がパスワードを設定して鍵をかけていた部屋が、全員退席して空になった後、 パスワードを知らない部外者が最初に入って部屋を乗っ取ってしまったらどうするのか? 」
次回は、 【第044回】部屋主が落ちても合言葉は破られない!ルームポリシー・パスワードの永続化と空部屋セキュリティ をお届けします。お楽しみに!
本番サービス : https://2ch.biz/
2026/10/26 修正済み