Web開発
Hard-Chat – サーバーレス、RAMのみのP2Pターミナルチャット
Hard-Chat – A serverless, RAM-only P2P terminal chat (github.com)
要約
Hard-Chatは、ブラウザ上で動作するエンドツーエンド暗号化されたP2Pチャットアプリケーションです。サーバーやアカウント、履歴の保存がなく、WebRTCを使用してデバイス間で直接通信します。AES-GCM 256ビット暗号化とPBKDF2による鍵導出を採用し、STUN/TURNサーバーでNAT越えをサポートします。ルームキーは100文字のランダムな文字列で、接続確立後2分で失効します。
全文翻訳
🔒 ゼロトレース・ターミナル
エンドツーエンドで暗号化されたP2Pチャットを、ブラウザ上で直接利用できます。サーバーなし、アカウントなし、履歴保存なし。
Hardlint Cybersecurity Team による
⚠️ 重要なお知らせ
このプロジェクトは、教育およびセキュリティ研究の目的で配布されています。ネットワークレベルの匿名性を保証するものではありません。会話の内容は保護しますが、接続している相手を必ずしも保護するものではありません。機密性の高い通信に使用する前に、攻撃対象領域と既知の制限セクションをお読みください。両方のデバイスで信頼できるVPNを使用することを強く推奨します。
✨ 特徴
🔐 エンドツーエンド暗号化 — AES-GCM 256ビット、PBKDF2(100,000イテレーション)経由で導出された鍵
🌐 真のP2P接続 — 2つのデバイス間の直接WebRTCリンク、メッセージを中継する中央サーバーなし
🚫 ゼロ永続性 — クッキーなし、localStorageなし、データベースなし:タブを閉じれば何も残りません
🔑 単一共有シークレット — ランダムに生成されたルームキー(100文字)、手動の技術設定は不要
🧹 パニックパージ — ボタン1つで鍵、接続状態、表示されているチャット履歴を即座に消去
📡 信頼性の高い接続 — 18のSTUN/TURNサーバーがフォールバックとして設定されており、制限の厳しいNAT(4G/5G、企業ネットワーク)の後でも機能します
🚀 使用方法
ページを開きます(HTTPSで提供される必要があります — 例:GitHub Pages経由、ローカルファイルとして開かないでください)
ホスト:[1] ルームの初期化 をクリック → 生成されたルームキーをコピーします
ルームキーを別のチャネル(対面、音声通話、別の暗号化アプリ)で連絡先に送信します
ゲスト:[2] ルームに接続 をクリック → 受信したルームキーを貼り付けます
接続を待ちます(通常数秒)→ チャットが開きます
2分以内に接続が確立されない場合、ルームキーは自動的に失効します:専用ボタンで新しいものを生成してください
📋 要件
WebRTCとWeb Crypto APIをサポートする最新のブラウザ(最近のChrome、Firefox、Edge、Safari)
両方のデバイスでのインターネットアクセス
ページはHTTPSで提供される必要があります(Web Crypto APIとWebRTCにはセキュアコンテキストが必要です)— ローカルファイルとして開いた場合は機能しません
接続試行中に両当事者が同時にページを開いている必要があります
ルームキーは、余分なスペースや改行なしで、正確に100文字すべてをコピーする必要があります
🏗️ アーキテクチャ概要
ゼロトレース・ターミナルは、2つのデバイスがWebRTC経由で直接ピアツーピア接続を確立し、エンドツーエンドで暗号化されたテキストメッセージを交換できる静的なWebアプリケーション(HTML/CSS/JS、プロプライエタリなバックエンドなし)です。
主要コンポーネント:
コンポーネント | 役割 | 技術
---|---|---
ユーザーインターフェース | レトロターミナルUI | プレーンHTML/CSS
シグナリング | 2つのピアがお互いを「見つける」のを助ける | PeerJS(パブリッククラウドブローカー)
データ転送 | 暗号化されたP2Pチャネル | WebRTC DataChannel
NATトラバーサル | ファイアウォール/NATを通過する | STUN + TURN (ICE)
暗号化 | メッセージコンテンツ保護 | AES-GCM 256ビット + PBKDF2
ホスティング | コード配布 | GitHub Pages(静的)
プロプライエタリなアプリケーションサーバーはありません。コードは完全に各ユーザーのブラウザで実行されます。関与する唯一の外部インフラストラクチャは、2つのデバイスをお互いに「紹介する」(シグナリング)ため、および直接接続が不可能な場合にトラフィックを中継するため(TURN)に使用されます。
🔄 運用フロー
ルームキー生成
ユーザーが「ルームの初期化(ホスト)」をクリックすると:
暗号学的に安全な乱数ジェネレーター(Math.random()ではなく、暗号目的には不向き)であるcrypto.getRandomValues()を使用して、ランダムな100文字の文字列(generate100CharCode())が生成されます。文字セットには、大文字/小文字の英字、数字、特殊記号(-_!@#$%^&*)が含まれており、100文字内でエントロピーを最大化しています。この文字列(ルームキー)は、両当事者が交換する必要のある唯一の共有シークレットであり、帯域外(例:音声メッセージ、対面、別の暗号化チャネル)で交換されます。
ルームキーからの鍵の導出
ルームキーから、異なる目的を持つ2つの独立した値が導出されます。
A. メッセージ暗号化鍵(PBKDF2 → AES-GCM)
PBKDF2( password = ルームキー, salt = "p2p-zero-trace-salt-v1"(固定、ハードコード済み), iterations = 100,000, hash = SHA-256 ) → 256ビットAES-GCM鍵
B. PeerJS識別子(切り捨てられたSHA-256)
SHA-256(ルームキー) → 最初の32文字の16進数、"ztt-"をプレフィックスとして付与
このIDは、ルームキー以外のものを交換することなく、ホストとゲストがお互いをPeerJSシグナリングブローカーで見つけられるようにするためにのみ使用されます。暗号学的な役割は果たしません。
固定ソルトに関する注意:PBKDF2ソルトはハードコードされており、すべてのセッションで同一です。これは、ルームキー自体が非常に高いエントロピー(100文字のランダム文字列)を持っているため許容されます。固定ソルトは、弱いパスワードが再利用されるシナリオではセキュリティを低下させますが、このケースには当てはまりません。
シグナリングフェーズ(PeerJS)
ホストはPeerオブジェクトを作成し、ルームキーから導出されたIDを使用してパブリックPeerJSクラウドブローカーに登録します。ゲストは、同じルームキーを貼り付けた後、同じIDを計算し、peer.connect(id)を呼び出します。PeerJSブローカーは、この初期交換(誰が誰と話したいか)のみを仲介します。メッセージコンテンツは、その時点では別のWebRTCチャネルを介して送信されるため、ブローカーはメッセージコンテンツを見ることも送信することもありません。
ICEネゴシエーション(NATトラバーサル)
2つのピアがお互いを「紹介」した後、WebRTCは有効なネットワークパスを見つけるためにICEネゴシエーションを開始します。
ホスト候補 — デバイスのローカルIPアドレス
サーバー反射(srflx)候補 — STUN経由で検出されたパブリックIP
リレー候補 — TURN経由で割り当てられ、直接接続が失敗した場合にのみ使用されます
設定されたICEサーバー(優先順位順):
専用メータード.ca TURN(独自の認証情報、共有なし)— stun.relay.metered.ca / global.relay.metered.ca
7つのパブリックSTUNフォールバック(Google ×3、Cloudflare、Twilio、Nextcloud、stunprotocol.org、freestun)
10の追加パブリックTURNフォールバックエンド(OpenRelay、freestun、numb.viagenie、ExpressTurn)— 専用TURNも失敗した場合にのみ使用されます
ブラウザはすべての組み合わせを自動的に試行し、動作チャネルを確立した最初のものを選択します(標準ICEアルゴリズム、WebRTCによって内部的に処理されます)。
タイムアウトとセッション失効
120秒以内に接続が確立されない場合、セッションは失効したと見なされます。
PeerおよびDataConnectionは破棄されます(peer.destroy()、conn.close())。
ステータスは[EXPIRED]と表示されます。
ルームキーは無効になります。
新しいルームキーを生成するためのボタン(ホスト)または新しいルームキーを入力するためのボタン(ゲスト)が表示されます。
これにより、ルームキーがパブリックブローカーで無期限に「リスニング」状態になるのを防ぎます。
🔐 メッセージ暗号化モデル
すべてのメッセージは、DataChannel経由で送信される前に個別に暗号化されます。
1. ランダムな12バイトのIVを生成します(crypto.getRandomValues)。
2. ciphertext = AES-GCM-Encrypt(key, IV, plaintext)
3. payload = IV || ciphertext(連結され、IVは平文で先頭に来ます)
4. payloadをUint8Arrayとしてconn.send()で送信します。
受信時:
1. 最初の12バイトをIVとして抽出します。
2. 残りは暗号文です(末尾に16バイトのGCM認証タグが含まれます)。
3. plaintext = AES-GCM-Decrypt(key, IV, ciphertext)
AES-GCMによって保証されるセキュリティプロパティ:
機密性 — 鍵なしでは誰も内容を読み取れません。
完全性/認証 — 通信中のパケットの改ざんは、サイレントに破損した出力を生成するのではなく、復号化の失敗([ERR: DECRYPTION_FAILED])を引き起こします。
このスキームでカバーされないこと:
セッション間の前方秘匿性 — 同じルームキーが複数のセッションで再利用された場合(新しいものが毎回生成される通常のフローではありません)、それらのセッションはすべて同じ導出鍵を共有します。
ピアID認証 — ルームキーを知っている人は誰でも接続できます。共有鍵の所有権を超えた「誰」であるかの暗号学的な検証はありません。
💾 データ永続性(クライアントサイド)
データ永続性に関する注記
ルームキー | なし。JS変数にのみ存在し、クローズ/リロードで消滅します。
導出されたAES-GCM鍵 | なし。同じく、ディスクに書き込まれることはありません。
チャットメッセージ | なし。DOM/RAMにのみ存在し、localStorage/IndexedDBはありません。
クッキー | なし。プロジェクトは一切使用しません。
アプリケーションログ | ローカルDevToolsコンソールのみ。どこにも送信されず、タブが閉じると消滅します。
「PANIC: PURGE SESSION」ボタンは明示的に以下を強制します。
接続のクローズ