SkyWay SFU RoomでInsertable Streams APIによるエンドツーエンド暗号化を試してみる

今回はSkyWayネタになりますが、他のWebRTCプラットフォームのSDKでも応用できるかもしれません。

# Insertable Streams APIについて

[Insertable Streams API](https://w3c.github.io/webrtc-insertable-streams/)は、WebRTCで通信するエンコード済みのメディアフレームに触ることが出来るAPIです。

SFUを利用した多人数通話のエンドツーエンド暗号化（以下、E2EE）や、メディアフレームに何らかのデータを付加して同期的に相手にする、メディアフレームの解析等、様々なユースケースに活用できる可能性があり、個人的にはWebRTC界隈でイマイチ押しのAPIです。

オススメの紹介記事を紹介します。

- [WebRTC Insertable Streams で映像ストリームをいじってみた](https://qiita.com/massie_g/items/2b0b6d4f61f1865b4da5)  / @massie_g
- [ついに映像同期したインライン Data に Webが対応。WebRTC Insertable Streams でのインラインData送受信のやり方](https://qiita.com/komasshu/items/269a8b0a375eab253c3a) / @komasshu

# ブラウザの対応状況

## Chrome

[Chrome Platform Statusの2020.10.25時点の情報](https://www.chromestatus.com/feature/6321945865879552)によれば、以下のプラットフォーム並びにバージョンから利用可能になっています。

> Chrome for desktop release 86
> Chrome for Android release 86

M86は2020.10.06に安定版としてリリース済みなので、普通に利用できます。

## 他のブラウザの状況

こちらも上記記事の引用ですが、Firefox、Safariとも実装に積極的のようです。
EdgeはChromiumベースになったので、情報はないですが、実装することはほぼ確定路線のような気がします。

> Consensus & Standardization
>
> Firefox: Positive
> Edge: No signal
> Safari: Positive

## 動作確認したい

動作をサクッと確認したい場合はWebRTC samplesがおすすめです。執筆時点で２つ掲載されていました。

- [WebRTC samples Peer connection end to end encryption](https://webrtc.github.io/samples/src/content/peerconnection/endtoend-encryption/)
- [WebRTC samples Insertable Streams Video Analyzer](https://webrtc.github.io/samples/src/content/peerconnection/video-analyzer/)

# SkyWayのSFURoomでInsertable Streams APIを利用して簡易E2EEビデオチャットを実装する

今回の記事では、Insertable Streams APIの一番メジャーなユースケースになるであろう、E2EEをSFURoomと組み合わせて動作させてみます。

記事執筆時点では、SkyWay JavaScript SDKを一部改修する必要があるため、その点は予めご了承ください。

尚、同JavaScript SDKのMediaConnectionを利用して、Insertable Streams APIで映像・音声と同期的にデータ送信を行う事もできます。興味がある方は [SkyWayの公式チュートリアルでInsertable Streamsを試してみた](https://qiita.com/ibuibu69/items/6da67f7dfcfbbbf2817c) をどうぞ。

## デモ

最初に今回の記事通りに実装するとどのような事が実現できるのかを、動画で紹介します。この動画では、予め決めておいた共通のキーを用いて3者通話を確立させ、その後、一人ずつキーを変更し映像が乱れることを確認しています。

![skyway_e2ee_demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6651/59bbdaf5-75d8-2e8c-9f02-6fb267a325e6.gif)

E2EEはその名の通り、エンド・ツー・エンドで暗号化する仕組みです。SFUサーバでは通常、WebRTCの暗号化（SRTP）を解いて映像のスプリットを行い、再度暗号化して送り出します。Insertable Streams APIで2重に暗号化を施すことによって、SFUサーバでWebRTCの暗号化を解いても内容が漏洩しないというメリットがあります。

## SkyWay JavaScript SDKの修正

Insertable Streams APIを利用する場合は、RTCPeerConnection Objectを触る必要があるため、取り出すAPIを追加します。

- 追加するコード

```javascript
  getSFUPeerConnection() {
    if (!this._connectionStarted) {
      return null;
    }
    return this._negotiator._pc;
  }
```

- 追加する場所
    - https://github.com/skyway/skyway-js-sdk/blob/master/src/peer/sfuRoom.js

コードの追加が終わったら[README](https://github.com/skyway/skyway-js-sdk/blob/master/README.md)に従ってビルドしてください。

## エンコード/デコード用の仕組みを用意

本質的な部分ではないため詳細は割愛しますが、今回は [WebRTC samples Peer connection end to end encryption](https://webrtc.github.io/samples/src/content/peerconnection/endtoend-encryption/) で利用されているWebWorkerを利用したエンジンを利用します。

暗号化の仕組みとしてはデータをXOR演算で加工しているだけなので、あくまでサンプルと考えたほうが良いと思います。

利用するにあたり1箇所修正しています。

```javascript
  } else if (operation === 'setCryptoKey') {
    if (event.data.currentCryptoKey !== currentCryptoKey) {
      // コメントアウトします
      //currentKeyIdentifier++;
    }
    currentCryptoKey = event.data.currentCryptoKey;
    useCryptoOffset = event.data.useCryptoOffset;
  }
```

- ソースコード
  - https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/endtoend-encryption/js/worker.js

## 公式サンプルをベースにE2EE対応の多人数ビデオチャットアプリを作る

ベースにするのは、[Roomサンプル](https://github.com/skyway/skyway-js-sdk/tree/master/examples/room)で、こちらを修正していきます。

ソースコードの全容は[gist](https://gist.github.com/yusuke84/e541b811f6268af6928e0765b2956f6b)に載せているので、参考にしてください。
手を加えた部分を解説していきます。

### 動作ブラウザの確認

RTCRtpSenderに`createEncodedStreams`が生えているかを確認します(RTCRtpReceiverにも同じものが生えている前提です)。

```javascript
const supportsInsertableStreams = !!RTCRtpSender.prototype.createEncodedStreams;
```

最初の仕様では、 `createEncodedVideoStreams` というAPIでしたが、こちらは既にdeprecatedになっています。

### Insertable Streamの有効化

RTCPeerConnectionオブジェクトをnewする際に、`encodedInsertableStreams`オプションを渡す必要があります。
SkyWayでは、new Peerにconfigで指定することで有効となります。

```javascript
const peer = (window.peer = new Peer({
    key: "APIキー",
    debug: 3,
    config: {
        encodedInsertableStreams: true
    },
}));
```

### workerの初期化とキーのセット

映像データのエンコード/デコード用にworkerを初期化します。
タイミング的にはRoom Joinのボタンをクリックしたタイミングとしました。初期化後は、`setCryptoKey()`を実行して、エンコードとデコードのための`CryptoKey`をセットします。補足ですが、このキーはデモ映のためにWebRTC Samplesに倣って変更されたら都度反映できるように、イベントをセットしておきます。

```javascript
joinTrigger.addEventListener('click', () => {
    if (!peer.open) {
        return;
    }

    const worker = new Worker('./libs/worker.js', {name: 'E2EE worker'});
    setCryptoKey();
    cryptoKey.addEventListener('change', setCryptoKey);

    // 省略

});
```

`CryptoKey`をセットするfunctionは以下のとおりです。キーを入力するUIを用意して、入力された値をworkerに渡して、`setCryptoKey`というオペレーションを実行します。

```javascript
const cryptoKey = document.getElementById('crypto-key');

// 省略

function setCryptoKey() {
    messages.textContent += `=== CryptoKey is ${cryptoKey.value} ===\n`;
    currentCryptoKey = cryptoKey.value;
    const useCryptoOffset = true;
    worker.postMessage({
        operation: 'setCryptoKey',
        currentCryptoKey,
        useCryptoOffset,
    });
}
```

`useCryptoOffset`については、worker.jsにコメントが有りました。利用するデコーダーに配慮して、先頭の制御情報は暗号化の対象外とするオプションのようです。SkyWayのSFUでは映像はVP8、音声はOpusとなるため、デフォルトでこの設定を有効にしています。

```
// If using crypto offset (controlled by a checkbox):
// Do not encrypt the first couple of bytes of the payload. This allows
// a middle to determine video keyframes or the opus mode being used.
// For VP8 this is the content described in
//   https://tools.ietf.org/html/rfc6386#section-9.1
// which is 10 bytes for key frames and 3 bytes for delta frames.
// For opus (where encodedFrame.type is not set) this is the TOC byte from
//   https://tools.ietf.org/html/rfc6716#section-3.1
//
// It makes the (encrypted) video and audio much more fun to watch and listen to
// as the decoder does not immediately throw a fatal error.
```
https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/endtoend-encryption/js/worker.js#L35

### 自分の映像・音声をエンコードする

自分の映像・音声のエンコード処理は、roomの`openイベント`をトリガーに行います。

```javascript
room.once('open', () => {
    messages.textContent += '=== You joined ===\n';

    if (supportsInsertableStreams){
        setTimeout(setLocalStreamEncoder, 1000);
    }

});
```

SkyWayのSDKは生のWebRTC APIを隠蔽しているため、今回の改造ではそれぞれのタイミングが重要となります。roomの`openイベント`発火時点では、エンコード処理で利用する`getSendersオブジェクト`がまだ生成されていないため（SkyWayのSFUサーバとのWebRTC的な接続が完了していないため）、以下のようなエラーが出ます。これを回避するために、1000ms実行を遅延させています。

```javascript
Uncaught TypeError: Cannot read property 'getSenders' of null
    at setLocalStreamEncoder (script.js:177)
```

`setLocalStreamEncoder`の中身は以下のとおりです。
先程SDKに追加した`getSFUPeerConnection`を利用してRTCPeerConnectionオブジェクトを参照し、`getSenders()`を実行します。SenderはVideo用、Audio用で複数あるため、forEachで全てのSenderに対して処理を実行しています。エンコードの具体的なやり方ですが、 `createEncodedStreams()`を実行して、エンコード済みのメディアフレームを読み取るインターフェース(`senderStreams.readableStream`)を取得し、worker.jsの`encode`オペレーションを実行します。

```javascript
function setLocalStreamEncoder(){
    let pc = room.getSFUPeerConnection();
    senderStreams = pc.getSenders().forEach(sender => {
        let senderStreams;
        senderStreams = sender.createEncodedStreams();
        worker.postMessage({
            operation: 'encode',
            readableStream: senderStreams.readableStream,
            writableStream: senderStreams.writableStream,
            }, [senderStreams.readableStream, senderStreams.writableStream]);
    });
}
```

worker.jsでは、[TransformStream](https://developer.mozilla.org/ja/docs/Web/API/TransformStream)を利用して、エンコード処理を行うfunctionをハンドラーとして登録します。その後、`readableStream`を[pipeThrough](https://developer.mozilla.org/ja/docs/Web/API/ReadableStream/pipeThrough)メソッドを利用して接続していき、最終的に[pipeTo](https://developer.mozilla.org/ja/docs/Web/API/ReadableStream/pipeTo)メソッドで`writableStream`に接続します。これで、エンコード処理がされたメディアフレームが相手に送られるようになります。

```javascript

  if (operation === 'encode') {
    const {readableStream, writableStream} = event.data;
    const transformStream = new TransformStream({
      transform: encodeFunction,
    });
    readableStream
        .pipeThrough(new TransformStream({
          transform: polyFillEncodedFrameMetadata, // M83 polyfill.
        }))
        .pipeThrough(transformStream)
        .pipeTo(writableStream);
```
https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/endtoend-encryption/js/worker.js#L135

polyFillEncodedFrameMetadataの補足てすが、M83までは`encodedFrame.getMetadata()`が実装されていなかった為、ポリフィルを用意していたようです。M86では以下の通り実装されているため、無視してもらって構いません。

```
encodedFrame.getMetadata()
> dependencies: []
> frameId: 1
> height: 480
> spatialIndex: 0
> synchronizationSource: 653435377
> temporalIndex: 0
> width: 640
```

### 相手の映像・音声をデコードする

相手の映像・音声のデコード処理は、roomの`streamイベント`をトリガーに行います。`streamイベント`はSFUサーバとのWebRTC通信が確立し、MediaStreamTrackを受信したタイミングで発火するため、遅延実行の必要はありません。

```javascript
room.on('stream', async stream => {
    if (supportsInsertableStreams){
        setRemoteStreamDecoder();
    }

   // 省略

});
```

`setRemoteStreamDecoder`の中身は以下のとおりです。
worker.jsを使ったデコードの処理は、先ほど紹介したエンコード処理と基本的には同じなので割愛します。尚、受信したメディアフレームを操作するためには、`getReceivers()`を実行して、`receiver`を取得します。

```javascript
function setRemoteStreamDecoder(){
    const pc = room.getSFUPeerConnection();
    receiverStreams = pc.getReceivers().forEach(receiver => {
        let receiverStreams;
        try {
            receiverStreams = receiver.createEncodedStreams();
            worker.postMessage({
            operation: 'decode',
            readableStream: receiverStreams.readableStream,
            writableStream: receiverStreams.writableStream,
        }, [receiverStreams.readableStream, receiverStreams.writableStream]);
        } catch (error) {
            // todo
        }
    });
};
```


ここのポイントはtry catchで処理を囲っている部分です。ポイントと言うかサンプルで実装をサボっている部分です、ごめんなさい。Roomの`Streamイベント`はRoomに参加者が入室する度に発火します。`receiver`については、参加人数×2（映像と音声がある場合）作成され、forEachで**毎回全て参照**しています。ブラウザの実装としては、`createEncodedStreams`を実行済みの`receiver`に対して、再度実行すると以下の例外が出るようです。

```
DOMException: Failed to execute 'createEncodedStreams' on 'RTCRtpReceiver': Encoded video streams already created
```

現時点では、createした`receiver`を開発者側で管理する必要がありそうです。

尚、この件は、[W3C Spec](https://github.com/w3c/webrtc-insertable-streams)のIssueで議論がなされていました。

> Add an API to know if createEncoded{Audio,Video}Streams was called #13
> https://github.com/w3c/webrtc-insertable-streams/issues/13

以上で、Roomサンプル修正箇所の紹介は終わります。

# 終わりに

SkyWayのようなWebRTCの生APIを隠蔽しているSDKは、簡単に使える反面、Insertable Streams APIに限らず生のWebRTC APIを使った機能を実現するにはハードルは高い思います。興味があるかたは、今回の記事を参考に実装してみてください。

# 参考にさせていただいた記事やリポジトリ

https://www.chromestatus.com/features/schedule
https://w3c.github.io/webrtc-insertable-streams/
https://github.com/w3c/webrtc-insertable-streams
https://github.com/webrtc/samples/tree/gh-pages/src/content/peerconnection/endtoend-encryption
https://qiita.com/massie_g/items/2b0b6d4f61f1865b4da5