コンテンツへスキップ
HexaTransfer
ブログへ戻る
技術詳解

WebRTC ファイル転送: Browser-to-Browser チュートリアル

ファイル directly between browsers using WebRTC. Data channels, signaling, and peer connection setup for real-time ファイル共有.

WebRTCのファイル転送は、最初のハンドシェイク後はサーバーをデータパスに挟まず、RTCDataChannelを使って2つのブラウザ間で直接バイトを送ります。必要なもの:SDPオファーとICE候補を交換するシグナリングチャネル(WebSocketまたは小さなリレー)、NAT検出のためのSTUNサーバー、対称NATのためのTURNフォールバック、信頼性のある順序付き配信用に設定されたデータチャネル。ピア接続が確立したら、16KB〜256KBのチャンクをchannel.send()し、DTLS 1.2で暗号化されたSCTP接続を通じてほぼネット回線速度でバイトが流れます。

WebRTCがピアツーピアを実現する仕組み

WebRTCは魔法ではなく、ICE+SDP+DTLS+SCTPが積み重なったものです。送信側はRTCPeerConnectionを作成し、データチャネルを開き、SDPオファーを生成して、シグナリングチャネル経由で受信側に送ります。受信側が応答します。両側はICE候補(ローカルIP・STUNによるリフレクシブIP・TURNによるリレーIP)を交換し、機能するパスを見つけます。DTLS 1.2がエンドツーエンドでハンドシェイクし、SCTPがその上で信頼性のあるストリーミングを提供し、バイトが流れ始めます。

暗号化は必須で組み込まれています。無効化できません。これは重要なセキュリティ上の利点です。WebSocketと異なり、ネットワークの盗聴者から保護するためにアプリケーション層の暗号化を追加することを覚えておく必要がありません。自分のシグナリングサーバーに対する真のE2EEには、悪意のあるシグナリングサーバーが独自のDTLS証明書を差し込める可能性があるため、AES-256-GCMの第2レイヤーを追加してください。

シグナリング:WebRTCが定義しない部分

WebRTCはシグナリングを意図的にあなたに委ねています。サーバー上のWebSocketで十分です。Firebase RealtimeDBに投稿した共有ルームコードでも、手動でペーストしたSDP文字列でも構いません。重要なのは、両ピアが最終的にオファー・アンサー・ICE候補のストリームを交換することです。

Nodeでの最小限のシグナリングサーバー:

const rooms = new Map();
wss.on('connection', (ws) => {
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === 'join') {
      const room = rooms.get(msg.room) ?? new Set();
      room.add(ws); rooms.set(msg.room, room);
    } else {
      for (const peer of rooms.get(msg.room) ?? []) {
        if (peer !== ws) peer.send(raw);
      }
    }
  });
});

20行以下です。サーバーはファイルのバイトを一切見ません。SDPとICEのメタデータだけです。月700円程度のVPSまたはCloudflare Workersでホストして、数百の同時転送を捌けます。

ピア接続の確立

GoogleのパブリックSTUNとTURNフォールバックで接続を作成します。

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    { urls: 'turn:turn.example.com:3478',
      username: 'user', credential: 'pass' }
  ]
});

住宅用接続の約15〜25%がSTUNで穴が開けられない対称NATの背後にあるため、TURNは本番サービスでは省略不可です。リレーされたトラフィックを処理できる帯域幅を持つVPSでcoturnを実行するか、XirsysやTwilioのようなマネージドTURNプロバイダーを使ってください。

オファーを作成する前にオファー側でデータチャネルを開きます。

const channel = pc.createDataChannel('file', {
  ordered: true, maxRetransmits: null
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', sdp: offer.sdp });

順序付き+無制限の再送はTCP相当の信頼性を提供します。非順序モードはより高速ですが、アプリケーション層での再組み立てが必要です。

チャネル用のファイルのチャンク化

SCTPのメッセージ当たりの実用的な上限は256KBで、古いブラウザは16KBを超えると苦労します。安全なデフォルトはFile.slice()でファイルから読み取った16KBのチャンクです。

const chunkSize = 16 * 1024;
let offset = 0;
channel.bufferedAmountLowThreshold = 1024 * 1024;
function sendNext() {
  while (offset < file.size && channel.bufferedAmount < 4 * 1024 * 1024) {
    const chunk = file.slice(offset, offset + chunkSize);
    chunk.arrayBuffer().then((buf) => channel.send(buf));
    offset += chunkSize;
  }
}
channel.onbufferedamountlow = sendNext;
sendNext();

bufferedAmountのウォーターマークにより、SCTPの送信バッファにギガバイトをキューイングしてメモリを使い果たすのを防ぎます。バッファが1MBを下回ったら4MBまで補充します。ローカルネットワークで40〜80MB/s、一般的な住宅ブロードバンドで5〜20MB/sに近いスループットが得られます。

バイトの受信とディスクへの書き込み

応答側では、着信チャネルをリッスンします。

pc.ondatachannel = ({ channel }) => {
  const chunks = [];
  let received = 0;
  channel.onmessage = ({ data }) => {
    chunks.push(data);
    received += data.byteLength;
    updateProgress(received);
    if (received === expectedSize) finish(chunks);
  };
};

500MBを超えるファイルはRAMに蓄積しないでください。File System Access APIを使ってディスクに直接ストリームしてください。

const handle = await window.showSaveFilePicker({ suggestedName: fileName });
const writable = await handle.createWritable();
channel.onmessage = async ({ data }) => writable.write(data);

FirefoxとSafariはまだshowSaveFilePickerをサポートしていないため、それらのブラウザではBlobURL.createObjectURLダウンロードにフォールバックし、上限を2GBにしてください。

バイトの前にファイルメタデータを送信する

受信者はバイトストリームが始まる前にファイル名・サイズ・MIMEタイプを知る必要があります。データチャネルで小さなJSONハンドシェイクを使います。

channel.send(JSON.stringify({
  type: 'metadata', name: file.name,
  size: file.size, mime: file.type, sha256: fileHash
}));

その後バイナリモードに切り替えます。受信者はdataが文字列かArrayBufferかで切り替えます。転送後の整合性検証のためにファイルのSHA-256を含め、アプリケーション層のAES-GCMを追加している場合はオプションでキーフィンガープリントも含めてください。

接続障害への対処

WebRTCデータチャネルは3つの方法で失敗します。ICEが完了しない(NAT・ファイアウォールブロック)、DTLSハンドシェイクが失敗する(クロックスキュー・証明書の問題)、または転送中に接続が切れる(ラップトップのスリープ・ネットワーク変更)。pc.oniceconnectionstatechangeをリッスンして'failed'または'disconnected'に対処してください。Chromeは'failed'に移行する前に数秒'disconnected'を保持しますが、Safariはより短気です。

転送中断時は、接続全体を再構築せずにICEを再起動します。

await pc.restartIce();
const offer = await pc.createOffer({ iceRestart: true });
// シグナリング経由で再送

再起動が失敗した場合は、サーバー経由の再開可能なアップロードにフォールバックしてください。WormholeやjustbeamitのようなWebRTC転送ツールの一部がこのパターンを採用しているのは、純粋なP2Pではどうしても機能しないネットワーク状況が全体の15%存在するからです。

自分のサーバーに対するエンドツーエンド暗号化の追加

WebRTC組み込みのDTLSはネットワーク攻撃者から守りますが、悪意のある・または侵害されたシグナリングサーバーからは守りません。真のE2EEには、両ピアがECDH P-256鍵ペアを生成し、短い帯域外コード(QRコードまたは6単語のパスフレーズ)で公開鍵を交換し、HKDF-SHA256で共有シークレットを導出し、sendを呼び出す前に各データチャネルメッセージをAES-256-GCMで暗号化します。これにより、シグナリングサーバーがDTLS証明書を差し替えても、ファイルを読むことができません。

HexaTransferはハイブリッドアーキテクチャを採用しています。クライアントサイドのAES-256-GCMで暗号文をサーバー側に保存し、P2Pの直接性とオフラインでも利用可能な共有を交換しています。hexatransfer.com で試せます — 無料、アカウント不要、最大10GB。

WebRTCが勝つ場面と負ける場面

WebRTCのファイル転送が輝くのは、両ピアが同時にオンラインの場合、自分のサーバーからのプライバシーが重要な場合、そしてリレー帯域幅のコストが大きい十分に大きなファイル(100MB超)の場合です。負けるのは、ユーザーが送信して離れたい場合、受信者が数時間後にリンクを開く場合、またはSTUNとTURNをブロックする制限的な企業ネットワークにいる場合です。汎用の転送ツールとして見ると、純粋なP2Pが快適にカバーできるのはユースケースの約60%です。残り40%はサーバーバックのフォールバックが必要です。これが「P2Pファイル転送」を謳うほぼすべての製品がアーキテクチャのどこかにリレーを持っている理由です。

エンドツーエンド暗号化で大容量ファイルを安全に送信

エンドツーエンド暗号化で最大10GBのファイルを無料で転送。アカウント不要。ファイルはアップロード前にブラウザで暗号化されるため、他の誰にも読まれません。

ファイルを送信