はじめに
Web上でリアルタイム通信するためのWebRTCは基本的な仕様は揃って来ていますが、今もより低レイヤーな部分を触るための仕様やAPIが提案されています。
今回はその1つである、Insertable Streamsについて体験してみました。
参考: https://webrtchacks.com/true-end-to-end-encryption-with-webrtc-insertable-streams/
仕様のEditor's Draft: https://w3c.github.io/webrtc-insertable-streams/
※2020.08.31 最新仕様に合わせて更新
Insertable Streams とは
データに触れるタイミング
メディアをWebRTCで送信するまでには、おおざっぱに言って次のステップがあります。(映像の場合)
- (S1) メディアから映像を1フレーム(1コマ)取得
- (S2) VP8やH.264などの方式でエンコード
- ←ココに割り込む
- (S3) フレームをRTPパケットに分割
- (S4) 暗号化
- (S5) 送信
受信はこの逆です。
- (R1) 受信
- (R2) 復号化
- (R3) RTPパケットからフレーム組み立て
- ←ココに割り込む
- (R4) VP8やH.264などの方式で1フレームをデコード
- (R5) メディアを表示
送信の(S2)と(S3)の間、受信の(R3)と(R4)に処理を挟むことができるのが、Insertable Stream になります。
エンコードされた状態、暗号化はされていない状態、でフレームデータに触ることができます。(S1)の直後のエンコードされる前の生のフレームデータに触れるわけではありません。(私は最初勘違いしました)
何が嬉しいのか?
Insertable Stream で何が嬉しいかは、ちょっと分かりにくいです。主に次の2つの用途があると言われています。
- (P2Pではなく)SFUのようなサーバーを介する通信において、SFUサーバーからはフレームの中身を見れなくする
- 2重の暗号化を行うことで、メディアをSFUサーバーから隠すEnd-to-End Encryption が可能
- フレームデータに、おまけのメタデータを追加して送信する
実装のステータス
まだデスクトップ版、Android版のChrome 83以降に試験的に実装している状態です。
利用するには、chrome://flags/#enable-experimental-web-platform-features を有効(enabled)にする必要があります。またオリジントライアルで特定のサイトで利用可能になるようです。
API
インサータブルストリームの取得
-
RTCRtpSender.createEncodedVideoStreams()RTCRtpSender.createEncodedStreams()- RTCInsertableStreams オブジェクトが返る
-
RTCRtpReceiver.createEncodedVideoStreams()RTCRtpReceiver.createEncodedStreams()- RTCInsertableStreams オブジェクトが返る
ストリームへのアクセス(提案のexplainerと、Chromeの実装が少し異なる)
- RTCInsertableStreams.readableStream
- RTCInsertableStreams.writableStream
ストリームデータの変換
const transform = new TransformStream({
start() {
// 開始時に呼ばれる
},
transform(encodedFrame, controller) {
// フレームごとに呼ばれる
// - encodedFrame ... RTCEncodedVideoFrame あるいは RTCEncodedAudioFrame
// - controller ... 現時点ではTransformStreamDefaultController
controller.enqueue(encodedFrame);
},
flush() {
// 終了/中断時に呼ばれる
}
});
またInsertable Stream を使うには、RTCPeerConnection生成時にオプションの指定が必要です。
const peer = new RTCPeerConnection({
//forceEncodedVideoInsertableStreams: true, // deprecated
//forceEncodedAudioInsertableStreams: true, // deprecated
encodedInsertableStreams: true
});
使ってみる
デモ
GitHub Pagesに簡単なデモを用意しました。超簡易な暗号化をイメージしています。
事前準備
- Chrome m83 以上(Canary等)で、#enable-experimental-web-platform-features フラグをオン(enabled)に設定
GitHub Pagesにアクセス
- https://mganeko.github.io/webrtc_insertable_demo/ にアクセス
- [Start Video]ボタンをクリックし、カメラから映像を取得
- 左に映像が表示される
- use Audio]がチェックされていると、マイクの音声も取得
- [Connect]ボタンをクリック
- ブラウザの単一タブ内で2つのPeerConnectionの通信が確立
- 右に受信した映像が表示される
- ストリームデータの加工
- 左の[XOR Sender data]をチェックすると、送信側でストリームのデータを加工
- 右の[XOR Receiver data]をチェックすると、受信側でストリームのデータを逆加工
- どちらも加工しない、あるいは加工する場合のみ、正常に右の映像が表示できる
- ※映像の乱れや回復が反映されるまで、時間がかかることがあります
- [Disconnect]ボタンをクリックすると通信切断
- [Stop Video]ボタンをクリックすると、映像取得終了
送信側と受信側がペアとなる暗号化/復号化を行わないと、映像/音声の内容が取り出せないことが分かります。
デモコード
- GitHubにコードがあります ... https://github.com/mganeko/webrtc_insertable_demo
- こちらを全面的に参考にしています ... https://github.com/webrtc/samples/tree/gh-pages/src/content/peerconnection/endtoend-encryption
- こちらでは WebWorkerを使っていますが、私のデモでは使わずにシンプルにしています
コードの説明
デモコードそのままではなく、処理の概要が分かるように抜粋して説明します。
送受信共通
let pc = new RTCPeerConnection({
//forceEncodedVideoInsertableStreams: true, // deprecated
//forceEncodedAudioInsertableStreams: true, // deprecated
encodedInsertableStreams: true
});
送信側
// メディアのトラックを追加
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
// Senderの設定処理を呼び出す
pc.getSenders().forEach(setupSenderTransform);
この時に Insertable Streamを使いつつ、データには一切触れない(何もしない)場合がこちらです。
function setupSenderTransform(sender) {
// const senderStreams = sender.track.kind === 'video' ? sender.createEncodedVideoStreams() : sender.createEncodedAudioStreams(); // deprecated
const senderStreams = sender.createEncodedStreams();
const readableStream = senderStreams.readableStream;
const writableStream = senderStreams.writableStream;
readableStream.pipeTo(writableStream);
}
readableStreamの出力を、そのままwitebleStreamの入力に接続することで、データをそのまま渡します。
あるいは、変換処理を通すがデータはコピーするだけの例はこちらです。意味はないですが処理の流れは理解できます。
// Insertable Streamをセットアップ
function setupSenderTransform(sender) {
//const senderStreams = sender.track.kind === 'video' ? sender.createEncodedVideoStreams() : sender.createEncodedAudioStreams(); // deprecated
const senderStreams = sender.createEncodedStreams();
const readableStream = senderStreams.readableStream;
const writableStream = senderStreams.writableStream;
const transformStream = new TransformStream({
transform: copyFunction,
});
readableStream
.pipeThrough(transformStream)
.pipeTo(writableStream);
}
// --- データーをコピーするだけの処理 ---
function copyFunction(chunk, controller) {
// --- new data ----
const view = new DataView(chunk.data);
const newData = new ArrayBuffer(chunk.data.byteLength);
const newView = new DataView(newData);
for (let i = 0; i < chunk.data.byteLength; i++) {
// just copy
newView.setInt8(i, view.getInt8(i));
}
chunk.data = newData;
// --- queue new chunk ---
controller.enqueue(chunk);
}
この「何もしない」「コピーするだけ」の場合は、受信側はInsertable Streamを使う必要はありません。
今回のデモのように、データをXORで加工する超簡易暗号化の場合はこちらです。
// Insertable Streamをセットアップ
function setupSenderTransform(sender) {
//const senderStreams = sender.track.kind === 'video' ? sender.createEncodedVideoStreams() : sender.createEncodedAudioStreams(); // deprecated
const senderStreams = sender.createEncodedStreams();
const readableStream = senderStreams.readableStream;
const writableStream = senderStreams.writableStream;
const transformStream = new TransformStream({
transform: encodeFunction,
});
readableStream
.pipeThrough(transformStream)
.pipeTo(writableStream);
}
const xorMask = 0xff; // XORをとるマスク
const cryptoOffset = 10; // 簡易暗号化をせず、そのままコピーするヘッダーの長さ
function encodeFunction(chunk, controller) {
// --- new data ----
const view = new DataView(chunk.data);
const newData = new ArrayBuffer(chunk.data.byteLength);
const newView = new DataView(newData);
for (let i = 0; i < chunk.data.byteLength; i++) {
// --- copy header -- ヘッダーはそのままコピーする
if (i < cryptoOffset) {
// just copy
newView.setInt8(i, view.getInt8(i));
continue;
}
// --- invert(XOR) ---
newView.setInt8(i, view.getInt8(i) ^ xorMask);
}
chunk.data = newData;
// --- queue new chunk ---
controller.enqueue(chunk);
}
映像の場合フレーム(ここではchunk)には種類があります。
- key ... 基準のフレームとなる1コマの静止画
- delta ... 前のフレームとの差分データ
データをすべてXORで加工してしまうと、受信側でこのフレームの種別が分からなくなるようです。VP8の場合keyフレームでは先頭10バイトが重要な情報なので、そこは加工せずにそのままコピーしています。
受信側
pc.ontrack = evt => {
setupReceiverTransform(evt.receiver); // Receiverの設定処理を呼び出す
// メディアを再生
recvVideo.srcObject = evt.streams[0];
recvVideo.play();
};
// Insertable Streamをセットアップ
function setupReceiverTransform(receiver) {
//const receiverStreams = receiver.track.kind === 'video' ? receiver.createEncodedVideoStreams() : receiver.createEncodedAudioStreams(); // deprecated
const receiverStreams = receiver.createEncodedStreams();
const readableStream = receiverStreams.readableStream;
const writableStream = receiverStreams.writableStream;
const transformStream = new TransformStream({
transform: decodeFunction,
});
readableStream
.pipeThrough(transformStream)
.pipeTo(writableStream);
}
受信側でもコピーするだけの処理もかけますが、ここでは省略します。XORによる超簡易暗号の復号化処理の場合はこちら。
const xorMask = 0xff; // XORをとるマスク
const cryptoOffset = 10; // 簡易暗号化をせず、そのままコピーするヘッダーの長さ
function decodeFunction(chunk, controller) {
// --- new data ----
const view = new DataView(chunk.data);
const newData = new ArrayBuffer(chunk.data.byteLength);
const newView = new DataView(newData);
for (let i = 0; i < chunk.data.byteLength - additionalSize; i++) {
// --- copy header --
if (i < cryptoOffset) {
// -- just copy --
newView.setInt8(i, view.getInt8(i));
continue;
}
// --- invert(XOR) copy ---
newView.setInt8(i, view.getInt8(i) ^ xorMask);
}
chunk.data = newData;
// --- queue new chunk ---
controller.enqueue(chunk);
}
XORなので、暗号化処理も復号化処理も中身は同じになっています。
デモとの違い
実際のデモでは、チェックボックスで暗号化/復号化のオン/オフを切り替えるようにしています。
何が嬉しいのか?(再び)
Insertable Stream の使いどころは実際なかなか難しい、というのが個人的な感触です。
(1) E2EE暗号化(End-to-End Encryption )
- (P2Pではなく)SFUのようなサーバーを介する通信において、2重の暗号化によりメディアをSFUサーバーから隠すEnd-to-End Encryption が可能
- だが、この暗号化の鍵をどうやって交換するか?
- また、暗号化の鍵はクライアント側でアプリに入力することになるが、そのアプリがカギをサーバーに渡していないことを保証できるのか?
- そこを信用するなら、SFUサーバーでデータを見ない/悪用しないことを信用して良いのでは?
技術的な実現性とは違うところで、考慮が必要ではないかと感じています。
※これについてはブラウザ側のコードをオープンソースで公開することで、おかしなことしていない(鍵をSFUサーバーに送っていない)ことを検証可能にするという考え方を知り、なるほどとうなりました。
フレームデータへのメタデータの付加
- 元のフレームの内容に関連したメタデータを付加することに意味がある
- が、エンコード済みなので元のフレームの内容を把握するのは困難
- 別の要素(時間とか)を使って、メターデータとフレームを関連付けることになる
- その場合、厳密にフレームとメタデータが一致していることは保証できない
- その差異を許容するなら、DataChannelなどの別の手段でも十分では?
メタデータの付与は面白い活用方法だと思いますが、タイミング合わせが厳密には困難なので、使い方は限定的に感じます。別の通信経路(DataChannelなど)が不要という意味では、利点があるかもしれません。
これについては私の想像力が足りていないだけで、今後おもしろい活用方法が出てくるかもしれませんね。
※Twitter経由で利用方法についてコメントいただきました。
- 音声データに、モーションデータを付与するのに使える
- VRモデルを使った通話に利用できそうです
- 字幕のテキストの付与も使えそうです
生のフレームデータ取得の期待
今の考え方を推し進めて、エンコード前のフレームデータが取得できたら、色々できるかもしれません。
RTCRtpSender.createRowVideoStreams()
例えばこのようなAPIが拡張され、生のフレームデータ(コマ画像)が取得できれば、想像が膨らみます。
- 顔検出、物体検出
- ウォーターマークの挿入
- 画像加工
Canvasを使うことで今でも実現可能ではありますが、より効率的に実装できる可能性があります。
終わりに
WebRTCの仕様と実装は、P2Pによるシンプルなユースケースは十分カバーができています。
現在はサイマルキャストなどSFUを使った多人数のユースケースへの対応や、より低レイヤーの情報にアクセスできるような仕様が徐々に提案、実装されるフェーズに移ってきています。
今後も継続的な進化が楽しみです。