こんにちは!2ちゃんねる互換掲示板システム「 つゔぁいちゃんねる(2ch.biz) 」の開発録、第45回です。
前回(第44回)では、空になったパスワード制ルームの乗っ取りを防ぐため、ポリシーとパスワードハッシュを記録する軽量レジストリテーブルを構築しました。
セキュリティは完璧になり、一件落着したかに見えました。しかし、私たちの心には1つの「拭いきれない引っかかり」がありました。
「私たちは『サーバーにログを一切残さない完全P2P』を標榜しているはずだ」
「それなのに、部屋のパスワードやポリシーを検証するためだけに、 サーバーのSQLiteデータベースにレコードを保存し続けるのは、本当に美しいアーキテクチャと言えるだろうか? 」
「データベースに部屋のレコードを1行も書かない」
「それでも、URLとパスワードが正しいかをサーバーが完璧に検証できる」
本稿では、Web標準のJWT(JSON Web Token)やKerberos認証の思想をわずか14文字のURL文字列に凝縮した、 「暗号化自己完結型ステートレスID(Self-contained Room Token)」 の誕生秘話を解説します!
1. 究極の問い:「DBレス」でどうやってパスワードを照合するのか?
通常、パスワード認証といえば、データベースに保存されたハッシュ値と照合するのがWeb開発の常識です。
-- 常識的なアプローチ(DBが必要)
SELECT pass_hash FROM rooms WHERE room_id = 'xxx';
しかし、もしサーバーのデータベースにテーブルを作らないとしたら、サーバーはどうやって「その部屋がパスワード制なのか」「ユーザーが入力した合言葉が合っているのか」を知ることができるのでしょうか?
答えは、 「必要な検証データをすべて暗号化・署名して、ルームID(URL)そのものの中に詰め込んでしまうこと」 でした。
2. わずか14文字に凝縮された「自己完結トークン」の構造
私たちが設計した自己完結型ルームIDは、見た目はごく普通のURLスラッグ(英数字12〜14文字)ですが、内部には極めて精密なデータ構造がパッキングされています。
$$\text{ID形式}: \quad \texttt{room_} + \langle\text{ポリシー}\rangle + \langle\text{難読化カウンタ}\rangle + \langle\text{パスワードトークン}\rangle + \langle\text{署名}\rangle$$
(例: room_p_7k9a2f_4m8x9p_c2)
4つの内包フィールド
-
ポリシー識別子(1文字) :
-
o: オープン制(誰でも自由に入室可能) -
p: パスワード制(合言葉が必要) -
i: 招待承認制(ホストの承認が必要)
-
-
難読化カウンター $N$(4〜6文字) :
- 第43回で実装したソルトXOR済みの発行連番(Base36)。
-
パスワード検証トークン(4文字) :
- パスワード制の場合のみ、サーバーの秘密鍵とカウンターソルトを用いて計算した $\text{HMAC-SHA256}(\text{passcode}, \text{salt})$ の上位4文字。
- ※パスワード平文は一切含まれず、不可逆なハッシュ検証コードのみ。オープン制の場合はランダムノイズを充填。
-
改ざん防止署名・チェックサム(2文字) :
- 上記の全文字列に対してサーバー秘密鍵で計算した署名。URLが1文字でも書き換えられていたらデコード時に即座に検知。
3. サーバー側判定フロー:DB照会ゼロの超速検証
ユーザーが招待URLをクリックしたり「既存の部屋に参加」を入力した際、シグナリングサーバーは一切のDBクエリを発行せず、メモリ上の計算だけで合否を判定します。
4. 実装コードの全貌
(1) 自己完結型トークンの生成
[!NOTE]
💡 カウンター難読化とステートレス署名の進化
本稿では「連番カウンターをBase36マスクして自己完結させるアプローチ」を解説しています。
その後のセキュリティ強化により、現在は連番カウンター自体を完全撤廃し、**「暗号論的乱数Nonce(CSPRNG)+ サーバー秘密鍵HMAC署名」**を純粋な11文字のBase36に凝縮した、連番推測が原理的に不可能なステートレストークン体系へと移行しています。
const SERVER_SECRET = crypto.randomBytes(32); // サーバー秘密鍵
const DUMMY_MASK = 0x2a7b; // 解説用のダミーマスク値
/**
* 自己完結型ルームIDの発行 (初期設計)
*/
function createSelfContainedRoomId(counter, policy, passcode) {
const policyChar = (policy === 'password') ? 'p' : (policy === 'invite' ? 'i' : 'o');
// 1. カウンターの難読化(Base36)
const maskedCounter = ((counter ^ DUMMY_MASK) >>> 0).toString(36);
// 2. パスワードトークンの生成(HMAC上位4文字)
let passToken = '0000';
if (policy === 'password' && passcode) {
const hmac = crypto.createHmac('sha256', SERVER_SECRET);
hmac.update(`${counter}:${passcode}`);
passToken = hmac.digest('hex').substring(0, 4);
} else {
passToken = crypto.randomBytes(2).toString('hex');
}
// 3. 署名(チェックサム)の計算
const body = `${policyChar}_${maskedCounter}_${passToken}`;
const signHmac = crypto.createHmac('sha256', SERVER_SECRET);
signHmac.update(body);
const signature = signHmac.digest('hex').substring(0, 2);
return `room_${body}_${signature}`;
}
(2) 完全ステートレスな検証ハンドラ
/**
* DBを一切使わない自己完結型IDの検証
*/
function verifyRoomToken(roomId, inputPasscode) {
const match = String(roomId).match(/^room_([poi])_([a-z0-9]+)_([a-z0-9]{4})_([a-z0-9]{2})$/);
if (!match) return { valid: false, error: 'INVALID_ROOM_ID' };
const [_, policyChar, maskedCounter, passToken, signature] = match;
// 1. 署名の検証(改ざんチェック)
const body = `${policyChar}_${maskedCounter}_${passToken}`;
const signHmac = crypto.createHmac('sha256', SERVER_SECRET);
signHmac.update(body);
if (signature !== signHmac.digest('hex').substring(0, 2)) {
return { valid: false, error: 'INVALID_ROOM_ID' };
}
// 2. カウンターの復元と範囲チェック
const counter = (parseInt(maskedCounter, 36) ^ DUMMY_MASK) >>> 0;
if (counter <= 0 || counter > currentMaxCounter) {
return { valid: false, error: 'INVALID_ROOM_ID' };
}
// 3. パスワードの検証
const isPasswordRequired = (policyChar === 'p');
if (isPasswordRequired) {
if (!inputPasscode) {
return { valid: false, error: 'INVALID_PASSWORD', message: 'パスコードを入力してください。' };
}
const hmac = crypto.createHmac('sha256', SERVER_SECRET);
hmac.update(`${counter}:${inputPasscode}`);
const expectedToken = hmac.digest('hex').substring(0, 4);
if (passToken !== expectedToken) {
return { valid: false, error: 'INVALID_PASSWORD', message: 'パスコードが正しくありません。' };
}
}
return {
valid: true,
counter: counter,
policy: isPasswordRequired ? 'password' : (policyChar === 'i' ? 'invite' : 'open')
};
}
5. 自動テストによる検証:DBゼロで全テストクリア
専用テストスクリプトを実行し、データベースにテーブルが1つも存在しない状態で検証を行いました。
| テスト項目 | 条件 | 期待結果 | 判定 |
|---|---|---|---|
| 出鱈目なID | 存在しないランダム文字列 | 署名不一致で INVALID_ROOM_ID
|
✅ PASS |
| URLの1文字改ざん | トークンの末尾文字を変更 | 署名不一致で即時検知 | ✅ PASS |
| 誤ったパスワード | 正しいID + wrong_pass
|
INVALID_PASSWORD で拒否 |
✅ PASS |
| 正しいパスワード | 正しいID + mypass123
|
空部屋が再開され、入室成功! | ✅ PASS |
| DBテーブルのレコード数 | SQLiteの SELECT count(*)
|
0 行(完全ゼロストレージ) | ✅ PASS |
6. まとめと次回予告
サーバーのデータベースに部屋のレコードを1行も残さないまま、強固なパスワード検証と空部屋再開を実現する「自己完結型ステートレスID」。
暗号学的ハッシュ関数(HMAC)とビットパッキングを駆使したこの設計により、つゔぁいちゃんねるのP2Pチャットは、 真の「ゼロストレージ&完全ステートレス」 という究極の高みに到達しました。
しかし、技術的にどんなに高度な暗号トークンを作っても、ユーザーにとって「使いにくい」のでは意味がありません。
「部屋を作るとき、こんな長くて複雑なトークンを手動でコピペさせるのか?」
「ボタンをワンクリックするだけで、一瞬で部屋が作れて招待URLがコピーされる親切なUIが必要だ!」
次回は、 【第046回】ワンクリックで秘密基地が完成!暗号化トークン組み込みルームIDの自動発行UI をお届けします。お楽しみに!
本番サービス : https://2ch.biz/