0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【第045回】DBサーバー不要の奇跡!暗号化パスワードとカウンターを内包した「自己完結型ステートレスID」

0
Posted at

こんにちは!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. ポリシー識別子(1文字) :
    • o: オープン制(誰でも自由に入室可能)
    • p: パスワード制(合言葉が必要)
    • i: 招待承認制(ホストの承認が必要)
  2. 難読化カウンター $N$(4〜6文字) :
    • 第43回で実装したソルトXOR済みの発行連番(Base36)。
  3. パスワード検証トークン(4文字) :
    • パスワード制の場合のみ、サーバーの秘密鍵とカウンターソルトを用いて計算した $\text{HMAC-SHA256}(\text{passcode}, \text{salt})$ の上位4文字。
    • ※パスワード平文は一切含まれず、不可逆なハッシュ検証コードのみ。オープン制の場合はランダムノイズを充填。
  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/

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?