T-CREATOR

WebRTC で E2EE ビデオ会議:Insertable Streams と鍵交換を実装する手順

WebRTC で E2EE ビデオ会議:Insertable Streams と鍵交換を実装する手順

WebRTC でリアルタイム通信を実装する際、セキュリティは最重要課題の一つです。特にビデオ会議では、通信内容が第三者に傍受されないよう End-to-End Encryption(E2EE)を導入することが求められます。本記事では、WebRTC の Insertable Streams API を活用し、実際に E2EE を実装する手順を段階的に解説していきます。鍵交換から暗号化・復号化まで、初心者の方でも理解できるよう丁寧にご説明しますね。

背景

WebRTC と E2EE の必要性

WebRTC(Web Real-Time Communication)は、ブラウザ間でリアルタイムに音声・映像・データをやり取りできる技術です。しかし、標準的な WebRTC 通信では、SFU(Selective Forwarding Unit)などの中継サーバーを経由する場合、サーバー側で通信内容を閲覧できてしまいます。

これに対し E2EE を導入すれば、送信者と受信者以外は暗号化されたデータしか見られなくなります。プライバシー保護やコンプライアンス要求に応えるため、E2EE の実装は今や必須と言えるでしょう。

Insertable Streams API の登場

従来、WebRTC のメディアストリームに対して独自の暗号化処理を施すことは困難でした。しかし、Insertable Streams API(別名:Encoded Transform)の登場により、RTP パケットのエンコード後・デコード前のタイミングで任意の処理を挿入できるようになりました。この API を使えば、メディアデータを暗号化してから送信し、受信側で復号化するという E2EE のフローを実現できます。

以下の図は、Insertable Streams を使った E2EE の基本的なデータフローを示しています。

mermaidflowchart LR
  sender["送信者<br/>(Sender)"] -->|生メディア| encoder["エンコーダ"]
  encoder -->|Encoded Frame| transform_send["Insertable Streams<br/>Transform (送信)"]
  transform_send -->|暗号化| encrypted["暗号化データ"]
  encrypted -->|ネットワーク| receiver["受信者<br/>(Receiver)"]
  receiver -->|暗号化データ| transform_recv["Insertable Streams<br/>Transform (受信)"]
  transform_recv -->|復号化| decoder["デコーダ"]
  decoder -->|生メディア| display["表示"]

上図のように、送信側では Encoded Frame を受け取った直後に暗号化し、受信側ではデコード前に復号化することで、ネットワーク上では常に暗号化されたデータのみが流れます。

課題

E2EE 実装における主な課題

WebRTC で E2EE を実装する際には、以下のような課題に直面します。

#課題説明
1鍵の生成と交換暗号化・復号化に使う鍵をどのように生成し、安全に相手と共有するか
2フレーム単位の暗号化処理リアルタイム通信では、フレームごとに高速に暗号化・復号化を行う必要がある
3鍵のローテーションセキュリティ強度を保つため、定期的に鍵を更新する仕組みが必要
4ブラウザ互換性Insertable Streams API はまだ比較的新しく、対応状況を確認する必要がある

特に鍵交換は、E2EE の根幹をなす重要な処理です。鍵が漏洩すれば暗号化の意味がなくなってしまうため、安全な方法で鍵を共有しなければなりません。

鍵交換の方式

鍵交換には複数の方式があります。

  • 事前共有鍵(PSK): あらかじめ安全な経路で鍵を共有しておく方法。シンプルだが、鍵の配布が課題
  • Diffie-Hellman 鍵交換: 公開情報のやり取りだけで共通鍵を生成できる方式。セキュリティ面で優れている
  • 公開鍵暗号方式: RSA や楕円曲線暗号を使い、公開鍵で暗号化した鍵を送信する方法

本記事では、Web Crypto API を用いた ECDH(Elliptic Curve Diffie-Hellman)鍵交換を採用し、共通鍵を生成する方法をご紹介します。

下図は ECDH 鍵交換の基本的な流れを示しています。

mermaidsequenceDiagram
  participant A as ピア A
  participant B as ピア B

  A->>A: 秘密鍵 a と<br/>公開鍵 A を生成
  B->>B: 秘密鍵 b と<br/>公開鍵 B を生成
  A->>B: 公開鍵 A を送信
  B->>A: 公開鍵 B を送信
  A->>A: 公開鍵 B と秘密鍵 a から<br/>共通鍵を導出
  B->>B: 公開鍵 A と秘密鍵 b から<br/>共通鍵を導出
  Note over A,B: 両者は同じ共通鍵を保持

ECDH では、お互いの公開鍵を交換するだけで、第三者には知られない共通鍵を生成できます。この仕組みにより、安全に鍵を共有できるのです。

解決策

E2EE 実装の全体フロー

WebRTC で E2EE を実現するには、以下のステップを踏みます。

  1. Web Crypto API で ECDH 鍵ペアを生成
  2. WebRTC のシグナリングで公開鍵を交換
  3. 共通鍵を導出し、AES-GCM などの共通鍵暗号に利用
  4. Insertable Streams API で Transform を設定
  5. 送信側:Encoded Frame を暗号化
  6. 受信側:暗号化データを復号化してデコーダへ渡す

この流れを踏むことで、送信者と受信者だけが通信内容を閲覧できる E2EE ビデオ会議が実現します。

以下の図は、実装全体のフローを示したものです。

mermaidflowchart TD
  Start["開始"] --> GenKey["ECDH 鍵ペア生成<br/>(Web Crypto API)"]
  GenKey --> Exchange["公開鍵交換<br/>(シグナリング経由)"]
  Exchange --> Derive["共通鍵導出<br/>(ECDH)"]
  Derive --> SetTransform["Insertable Streams<br/>Transform 設定"]
  SetTransform --> Send["送信側:<br/>暗号化処理"]
  SetTransform --> Recv["受信側:<br/>復号化処理"]
  Send --> Network["ネットワーク送信"]
  Network --> Recv
  Recv --> Display["メディア表示"]
  Display --> End["終了"]

それでは、各ステップを具体的に見ていきましょう。

ステップ 1: ECDH 鍵ペアの生成

まず、Web Crypto API を使って ECDH 鍵ペアを生成します。この鍵ペアは、公開鍵交換と共通鍵導出に使用されます。

typescript// ECDH 鍵ペアを生成する関数
async function generateECDHKeyPair(): Promise<CryptoKeyPair> {
  // P-256 曲線を使った ECDH 鍵ペアを生成
  const keyPair = await crypto.subtle.generateKey(
    {
      name: 'ECDH',
      namedCurve: 'P-256', // 楕円曲線のパラメータ
    },
    true, // 鍵をエクスポート可能にする
    ['deriveKey', 'deriveBits'] // 用途を指定
  );

  return keyPair;
}

generateKey メソッドで ECDH 鍵ペアを生成します。namedCurve: "P-256" は、NIST が標準化した楕円曲線を指定しています。true を指定することで、後で公開鍵をエクスポートできるようになります。

ステップ 2: 公開鍵のエクスポートと交換

生成した公開鍵をエクスポートし、相手に送信します。公開鍵は JSON 形式でやり取りするため、JWK(JSON Web Key)形式を使用します。

typescript// 公開鍵を JWK 形式でエクスポート
async function exportPublicKey(
  publicKey: CryptoKey
): Promise<JsonWebKey> {
  const jwk = await crypto.subtle.exportKey(
    'jwk',
    publicKey
  );
  return jwk;
}

// JWK 形式の公開鍵をインポート
async function importPublicKey(
  jwk: JsonWebKey
): Promise<CryptoKey> {
  const publicKey = await crypto.subtle.importKey(
    'jwk',
    jwk,
    {
      name: 'ECDH',
      namedCurve: 'P-256',
    },
    true,
    [] // 公開鍵なので用途は空
  );

  return publicKey;
}

exportKey で公開鍵を JWK 形式に変換し、シグナリングサーバー経由で相手に送信します。受信側では importKey で JWK を CryptoKey オブジェクトに戻します。

シグナリングでの公開鍵交換は、以下のように WebSocket や HTTP 経由で行います。

typescript// シグナリングで公開鍵を送信する例
async function sendPublicKey(
  signalingConnection: WebSocket,
  publicKey: CryptoKey
): Promise<void> {
  const jwk = await exportPublicKey(publicKey);

  // シグナリングメッセージとして送信
  signalingConnection.send(
    JSON.stringify({
      type: 'public-key',
      key: jwk,
    })
  );
}

相手から受信した公開鍵は、次のステップで共通鍵の導出に使用します。

ステップ 3: 共通鍵の導出

自分の秘密鍵と相手の公開鍵から、ECDH を使って共通鍵を導出します。この共通鍵を AES-GCM 暗号化に使用します。

typescript// 共通鍵を導出する関数
async function deriveSharedKey(
  privateKey: CryptoKey,
  peerPublicKey: CryptoKey
): Promise<CryptoKey> {
  // ECDH で共通鍵を導出
  const sharedKey = await crypto.subtle.deriveKey(
    {
      name: 'ECDH',
      public: peerPublicKey, // 相手の公開鍵
    },
    privateKey, // 自分の秘密鍵
    {
      name: 'AES-GCM', // 導出する鍵のアルゴリズム
      length: 128, // 128 ビット鍵
    },
    false, // エクスポート不可にしてセキュリティ強化
    ['encrypt', 'decrypt'] // 暗号化・復号化に使用
  );

  return sharedKey;
}

deriveKey メソッドで、相手の公開鍵と自分の秘密鍵から AES-GCM 用の共通鍵を生成します。この鍵は両者で同じものになるため、これを使って暗号化・復号化が可能になります。

ステップ 4: Insertable Streams Transform の設定

WebRTC の RTCRtpSenderRTCRtpReceiver に対して、Insertable Streams の Transform を設定します。Transform は、メディアフレームに対して暗号化・復号化処理を行うための仕組みです。

typescript// Insertable Streams を有効化する関数
function setupInsertableStreams(
  sender: RTCRtpSender,
  receiver: RTCRtpReceiver,
  sharedKey: CryptoKey
): void {
  // 送信側の Transform 設定
  const senderStreams = sender.createEncodedStreams();
  const senderTransform = new TransformStream({
    transform: async (encodedFrame, controller) => {
      // 暗号化処理(後述)
      const encryptedFrame = await encryptFrame(
        encodedFrame,
        sharedKey
      );
      controller.enqueue(encryptedFrame);
    },
  });

  senderStreams.readable
    .pipeThrough(senderTransform)
    .pipeTo(senderStreams.writable);

  // 受信側の Transform 設定
  const receiverStreams = receiver.createEncodedStreams();
  const receiverTransform = new TransformStream({
    transform: async (encodedFrame, controller) => {
      // 復号化処理(後述)
      const decryptedFrame = await decryptFrame(
        encodedFrame,
        sharedKey
      );
      controller.enqueue(decryptedFrame);
    },
  });

  receiverStreams.readable
    .pipeThrough(receiverTransform)
    .pipeTo(receiverStreams.writable);
}

createEncodedStreams() で Encoded Frame を扱うストリームを取得します。TransformStream を作成し、transform 関数内で暗号化・復号化処理を実行します。pipeThrough でストリームを接続することで、フレームが自動的に処理されます。

図で理解できる要点

  • RTCRtpSender → Encoded Frame → 暗号化 Transform → 送信
  • RTCRtpReceiver → 暗号化 Frame → 復号化 Transform → デコード
  • Transform は双方向で独立して動作し、リアルタイム処理を実現

具体例

暗号化処理の実装

送信側では、Encoded Frame を AES-GCM で暗号化します。暗号化には IV(Initialization Vector)が必要なため、ランダムに生成して Frame の先頭に付加します。

typescript// Encoded Frame を暗号化する関数
async function encryptFrame(
  encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
  sharedKey: CryptoKey
): Promise<RTCEncodedVideoFrame | RTCEncodedAudioFrame> {
  // フレームデータを取得
  const data = new Uint8Array(encodedFrame.data);

  // IV(初期化ベクトル)を生成(12 バイト)
  const iv = crypto.getRandomValues(new Uint8Array(12));

  // AES-GCM で暗号化
  const encryptedData = await crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv: iv,
    },
    sharedKey,
    data
  );

  // IV と暗号化データを結合
  const combined = new Uint8Array(
    iv.length + encryptedData.byteLength
  );
  combined.set(iv, 0);
  combined.set(new Uint8Array(encryptedData), iv.length);

  // 暗号化データをフレームに設定
  encodedFrame.data = combined.buffer;

  return encodedFrame;
}

crypto.getRandomValues で安全な乱数を生成し、IV として使用します。crypto.subtle.encrypt で AES-GCM 暗号化を実行し、IV と暗号化データを結合して Frame に格納します。受信側では、この IV を使って復号化を行います。

復号化処理の実装

受信側では、暗号化された Frame から IV を取り出し、残りのデータを復号化します。

typescript// Encoded Frame を復号化する関数
async function decryptFrame(
  encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
  sharedKey: CryptoKey
): Promise<RTCEncodedVideoFrame | RTCEncodedAudioFrame> {
  // フレームデータを取得
  const data = new Uint8Array(encodedFrame.data);

  // IV を抽出(最初の 12 バイト)
  const iv = data.slice(0, 12);

  // 暗号化データを抽出(残り)
  const encryptedData = data.slice(12);

  try {
    // AES-GCM で復号化
    const decryptedData = await crypto.subtle.decrypt(
      {
        name: 'AES-GCM',
        iv: iv,
      },
      sharedKey,
      encryptedData
    );

    // 復号化データをフレームに設定
    encodedFrame.data = decryptedData;
  } catch (error) {
    // 復号化失敗時はエラーログを出力
    console.error('Decryption failed:', error);
    // フレームをスキップ
    throw error;
  }

  return encodedFrame;
}

slice で IV と暗号化データを分離し、crypto.subtle.decrypt で復号化します。復号化に失敗した場合は catch でエラーを捕捉し、そのフレームをスキップします。

完全な実装例

以下は、WebRTC の PeerConnection 確立から E2EE 設定までを含む完全なコード例です。

typescript// 型定義
interface SignalingMessage {
  type: string;
  [key: string]: any;
}

// WebRTC と E2EE のセットアップクラス
class E2EEWebRTCClient {
  private peerConnection: RTCPeerConnection;
  private signalingConnection: WebSocket;
  private localKeyPair: CryptoKeyPair | null = null;
  private sharedKey: CryptoKey | null = null;

  constructor(signalingUrl: string) {
    // PeerConnection を作成
    this.peerConnection = new RTCPeerConnection({
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
      ],
    });

    // シグナリング接続を確立
    this.signalingConnection = new WebSocket(signalingUrl);
    this.setupSignaling();
  }

  // 初期化処理
  async initialize(): Promise<void> {
    // ECDH 鍵ペアを生成
    this.localKeyPair = await generateECDHKeyPair();

    // 公開鍵を相手に送信
    const publicKeyJwk = await exportPublicKey(
      this.localKeyPair.publicKey
    );
    this.sendSignalingMessage({
      type: 'public-key',
      key: publicKeyJwk,
    });
  }

  // シグナリング処理のセットアップ
  private setupSignaling(): void {
    this.signalingConnection.onmessage = async (event) => {
      const message: SignalingMessage = JSON.parse(
        event.data
      );

      switch (message.type) {
        case 'public-key':
          // 相手の公開鍵を受信
          await this.handlePublicKey(message.key);
          break;
        case 'offer':
          // WebRTC オファーを受信
          await this.handleOffer(message.offer);
          break;
        case 'answer':
          // WebRTC アンサーを受信
          await this.handleAnswer(message.answer);
          break;
        case 'ice-candidate':
          // ICE 候補を受信
          await this.handleIceCandidate(message.candidate);
          break;
      }
    };
  }

  // 相手の公開鍵を処理
  private async handlePublicKey(
    peerPublicKeyJwk: JsonWebKey
  ): Promise<void> {
    if (!this.localKeyPair) {
      throw new Error('Local key pair not generated');
    }

    // 公開鍵をインポート
    const peerPublicKey = await importPublicKey(
      peerPublicKeyJwk
    );

    // 共通鍵を導出
    this.sharedKey = await deriveSharedKey(
      this.localKeyPair.privateKey,
      peerPublicKey
    );

    console.log('Shared key derived successfully');
  }

  // ローカルメディアを追加
  async addLocalMedia(stream: MediaStream): Promise<void> {
    for (const track of stream.getTracks()) {
      const sender = this.peerConnection.addTrack(
        track,
        stream
      );

      // 共通鍵が導出済みなら Insertable Streams を設定
      if (this.sharedKey) {
        this.setupSenderTransform(sender);
      }
    }
  }

  // 送信側 Transform を設定
  private setupSenderTransform(sender: RTCRtpSender): void {
    if (!this.sharedKey) return;

    const senderStreams = sender.createEncodedStreams();
    const transform = new TransformStream({
      transform: async (encodedFrame, controller) => {
        if (this.sharedKey) {
          const encrypted = await encryptFrame(
            encodedFrame,
            this.sharedKey
          );
          controller.enqueue(encrypted);
        }
      },
    });

    senderStreams.readable
      .pipeThrough(transform)
      .pipeTo(senderStreams.writable);
  }

  // 受信側 Transform を設定
  private setupReceiverTransform(
    receiver: RTCRtpReceiver
  ): void {
    if (!this.sharedKey) return;

    const receiverStreams = receiver.createEncodedStreams();
    const transform = new TransformStream({
      transform: async (encodedFrame, controller) => {
        if (this.sharedKey) {
          try {
            const decrypted = await decryptFrame(
              encodedFrame,
              this.sharedKey
            );
            controller.enqueue(decrypted);
          } catch (error) {
            console.error(
              'Frame decryption failed, skipping frame'
            );
          }
        }
      },
    });

    receiverStreams.readable
      .pipeThrough(transform)
      .pipeTo(receiverStreams.writable);
  }

  // リモートトラックのハンドリング
  onRemoteTrack(
    callback: (track: MediaStreamTrack) => void
  ): void {
    this.peerConnection.ontrack = (event) => {
      const receiver = event.receiver;

      // 受信側 Transform を設定
      if (this.sharedKey) {
        this.setupReceiverTransform(receiver);
      }

      callback(event.track);
    };
  }

  // オファーを作成して送信
  async createOffer(): Promise<void> {
    const offer = await this.peerConnection.createOffer();
    await this.peerConnection.setLocalDescription(offer);

    this.sendSignalingMessage({
      type: 'offer',
      offer: offer,
    });
  }

  // オファーを処理してアンサーを送信
  private async handleOffer(
    offer: RTCSessionDescriptionInit
  ): Promise<void> {
    await this.peerConnection.setRemoteDescription(offer);

    const answer = await this.peerConnection.createAnswer();
    await this.peerConnection.setLocalDescription(answer);

    this.sendSignalingMessage({
      type: 'answer',
      answer: answer,
    });
  }

  // アンサーを処理
  private async handleAnswer(
    answer: RTCSessionDescriptionInit
  ): Promise<void> {
    await this.peerConnection.setRemoteDescription(answer);
  }

  // ICE 候補を処理
  private async handleIceCandidate(
    candidate: RTCIceCandidateInit
  ): Promise<void> {
    await this.peerConnection.addIceCandidate(candidate);
  }

  // シグナリングメッセージを送信
  private sendSignalingMessage(
    message: SignalingMessage
  ): void {
    this.signalingConnection.send(JSON.stringify(message));
  }
}

このクラスは、WebRTC の PeerConnection 管理、シグナリング処理、ECDH 鍵交換、Insertable Streams による暗号化・復号化をすべて統合しています。

使用例

以下は、このクラスを実際に使用する例です。

typescript// 使用例
async function startE2EEVideoCall(): Promise<void> {
  // E2EE クライアントを作成
  const client = new E2EEWebRTCClient(
    'wss://your-signaling-server.com'
  );

  // 初期化(鍵生成・公開鍵送信)
  await client.initialize();

  // ローカルメディアを取得
  const localStream =
    await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true,
    });

  // ローカルビデオを表示
  const localVideo = document.getElementById(
    'local-video'
  ) as HTMLVideoElement;
  localVideo.srcObject = localStream;

  // ローカルメディアを PeerConnection に追加
  await client.addLocalMedia(localStream);

  // リモートトラックを受信したら表示
  client.onRemoteTrack((track) => {
    const remoteVideo = document.getElementById(
      'remote-video'
    ) as HTMLVideoElement;
    const remoteStream = new MediaStream([track]);
    remoteVideo.srcObject = remoteStream;
  });

  // オファーを作成(発信側の場合)
  await client.createOffer();

  console.log('E2EE video call started');
}

// アプリケーション起動時に実行
startE2EEVideoCall().catch(console.error);

この例では、getUserMedia でローカルメディアを取得し、E2EE クライアントに追加しています。リモートトラックを受信したら、ビデオ要素に表示します。

HTML 構造

上記の JavaScript を動作させるための HTML は以下のようになります。

html<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>WebRTC E2EE ビデオ会議</title>
  </head>
  <body>
    <h1>WebRTC E2EE ビデオ会議</h1>

    <!-- ローカルビデオ -->
    <div>
      <h2>ローカル</h2>
      <video
        id="local-video"
        autoplay
        muted
        playsinline
        width="400"
      ></video>
    </div>

    <!-- リモートビデオ -->
    <div>
      <h2>リモート</h2>
      <video
        id="remote-video"
        autoplay
        playsinline
        width="400"
      ></video>
    </div>

    <script src="app.js"></script>
  </body>
</html>

エラー処理とデバッグ

実装時には、以下のようなエラーが発生する可能性があります。

#エラーコード原因解決方法
1NotSupportedErrorInsertable Streams API 未対応Chrome 86+、Edge 86+ など対応ブラウザを使用
2OperationError復号化失敗鍵が正しく共有されているか確認、IV の取り扱いを見直す
3InvalidStateErrorPeerConnection の状態が不正setLocalDescription / setRemoteDescription の順序を確認
4DataError公開鍵のインポート失敗JWK 形式が正しいか確認

復号化失敗のデバッグ例

typescript// 復号化失敗時の詳細ログ
async function decryptFrameWithDebug(
  encodedFrame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
  sharedKey: CryptoKey
): Promise<RTCEncodedVideoFrame | RTCEncodedAudioFrame> {
  const data = new Uint8Array(encodedFrame.data);

  console.log('Frame data length:', data.length);

  const iv = data.slice(0, 12);
  const encryptedData = data.slice(12);

  console.log('IV:', Array.from(iv));
  console.log(
    'Encrypted data length:',
    encryptedData.length
  );

  try {
    const decryptedData = await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv: iv },
      sharedKey,
      encryptedData
    );

    console.log('Decryption successful');
    encodedFrame.data = decryptedData;
  } catch (error) {
    console.error('Decryption error:', error);
    console.error('Error name:', (error as Error).name);
    console.error(
      'Error message:',
      (error as Error).message
    );
    throw error;
  }

  return encodedFrame;
}

このように詳細なログを出力することで、どの段階で問題が発生しているかを特定できます。

ブラウザ対応状況の確認

Insertable Streams API は比較的新しい機能なので、以下のコードで対応状況を確認しましょう。

typescript// Insertable Streams API 対応確認
function checkInsertableStreamsSupport(): boolean {
  // RTCRtpSender に createEncodedStreams メソッドがあるか確認
  const pc = new RTCPeerConnection();
  const audio = new MediaStreamTrack();
  const sender = pc.addTrack(audio);

  const supported =
    typeof sender.createEncodedStreams === 'function';

  pc.close();

  if (supported) {
    console.log(
      '✓ Insertable Streams API はサポートされています'
    );
  } else {
    console.warn(
      '✗ Insertable Streams API はサポートされていません'
    );
  }

  return supported;
}

// 実行
if (!checkInsertableStreamsSupport()) {
  alert(
    'このブラウザでは E2EE 機能を利用できません。Chrome 86+ をご利用ください。'
  );
}

鍵のローテーション

セキュリティを強化するため、定期的に鍵を更新することが推奨されます。以下は、一定時間ごとに鍵を再生成する例です。

typescript// 鍵ローテーション機能
class E2EEKeyRotation {
  private client: E2EEWebRTCClient;
  private rotationInterval: number = 60000; // 60 秒ごと
  private timerId: number | null = null;

  constructor(client: E2EEWebRTCClient) {
    this.client = client;
  }

  // 鍵ローテーションを開始
  start(): void {
    this.timerId = window.setInterval(async () => {
      console.log('Rotating encryption key...');
      await this.client.initialize(); // 新しい鍵ペアを生成
    }, this.rotationInterval);
  }

  // 鍵ローテーションを停止
  stop(): void {
    if (this.timerId) {
      clearInterval(this.timerId);
      this.timerId = null;
    }
  }
}

// 使用例
const keyRotation = new E2EEKeyRotation(client);
keyRotation.start();

ただし、鍵をローテーションする際は、両者で同期を取る必要があるため、シグナリング経由で鍵更新のタイミングを通知する実装が必要です。

図で理解できる要点

  • 暗号化時は IV を生成してデータに付加
  • 復号化時は IV を分離してから AES-GCM で復号
  • エラーハンドリングでフレームスキップを実装
  • 定期的な鍵ローテーションでセキュリティ強化

まとめ

本記事では、WebRTC で E2EE ビデオ会議を実装する方法を、Insertable Streams API と ECDH 鍵交換を中心に解説しました。

実装のポイント

  • ECDH 鍵交換: Web Crypto API を使い、安全に共通鍵を導出できます
  • Insertable Streams API: Encoded Frame に対して暗号化・復号化処理を挿入できます
  • AES-GCM 暗号化: 高速で安全な暗号化方式で、リアルタイム通信に適しています
  • IV の管理: 暗号化ごとに異なる IV を生成し、データに付加することが重要です
  • エラーハンドリング: 復号化失敗時にフレームをスキップすることで、通信を継続できます

セキュリティ上の注意点

本記事の実装は、E2EE の基本的な仕組みを理解するためのものです。本番環境で使用する際は、以下の点に注意してください。

  • 鍵の検証: 中間者攻撃を防ぐため、公開鍵のフィンガープリントを別経路で確認する
  • Perfect Forward Secrecy: 鍵が漏洩しても過去の通信を保護するため、セッションごとに鍵を変える
  • 鍵のライフサイクル管理: 鍵の生成、保存、破棄を適切に管理する
  • 署名の追加: 公開鍵に電子署名を付けて、なりすましを防ぐ

次のステップ

E2EE の実装をさらに発展させるには、以下のような機能追加を検討してみてください。

  • 複数参加者への対応: グループ通話での鍵管理(SFU + E2EE)
  • 音声・映像の個別制御: トラックごとに異なる鍵を使用
  • 鍵の永続化: IndexedDB に鍵を保存して再接続に対応
  • UI の実装: 鍵交換の進捗状況や暗号化ステータスを表示

WebRTC と E2EE を組み合わせることで、プライバシーを守りながら高品質なビデオ会議を実現できます。ぜひ本記事を参考に、安全で快適なコミュニケーションツールを開発してみてください。

関連リンク