🔄

素HTTPシグナリングによるWebRTC接続実験

2023/09/28に公開

EDIT: 感想(パフォーマンスの項)に注記。


(SSL証明書の無い)素のHTTPサーバー + libdatachannelを搭載したローカルネットワーク上のデバイス と Webアプリの間でP2P通信できた。応用はこれから考える。

https://twitter.com/okuoku/status/1707333056305262765

デモでは、iOS 15のiPodからローカルのデバイス 192.168.1.211 に接続し、デバイスへのアップロード帯域を測定している。 ...最新のiOS 17やmacOS SonomaのSafariでは正常に動作しなかった( 多分Safariのバグ )。AndroidのChrome/FirefoxやSafari以外のデスクトップブラウザでは正常に動作する。

デモのソースコードは https://github.com/okuoku/fnest-proto/tree/zenn-demo-Sep28 に置いた。要HTTPSリバースproxy。

モチベーション

Webブラウザをユーザーインターフェースとしてデバイス(ネットワークカメラ等)を制御する上で、デバイスと直接広帯域な通信(画面の転送とか、デバッグとか)を行いたい。ただし、 サーバー費用はゼロにしたい ... 制御用のWebアプリはGitHub Pagesでホストすればタダだが、シグナリングサーバーはサーバー代が掛かる & デバイスをインターネットに接続する必要があるため避けたい。

WebRTCの接続を確立するには、デバイス間でIPアドレス等の接続情報を交換する(= シグナリング)必要があり、例えば PeerJS を始めとしたp2pライブラリはこれにWebSocketを使用している。httpsページから使用されるWebSocketでは、正当なSSL証明書を持っていないデバイスとは直接通信できない。

(WebRTCはhttpsでホストされたサイト(secure context)からしか使用できない ことに注意する。)

このため、"素の (Secureでない) HTTPサーバー + WebRTC機能のみ搭載したデバイス" とWebブラウザの間で直接p2p通信できる 何らかのハック を検討することにした。

構成

デバイス上のWebRTC実装としては libdatachannel がある。これは 従来LGPLv2.1 or laterだったがMPLv2になり プロジェクトに組込みやすくなった。

今回は node.js スクリプトをデバイスに見立てて実装した。

  • デバイス側: デモのソース
  • Webアプリ側:
    • 動作確認済ブラウザ: Windows/Android/macOS Chrome、 macOS/iOS Safari (最近動かなくなった)、Windows/macOS Firefox
    • httpsサーバー: Hono + node-server

(今回は単一のスクリプトでWebアプリ(https)側もホストしてしまっているが、当然Webアプリとデバイスはnode.jsの内部では通信していない)

実装

WebRTCの接続といっても、必要最低限で良ければ特に難しくない。接続するピア同士でテキストデータを1往復交換すれば完了する。交換するテキストデータには接続に使えるIPアドレス等の情報が入っていて、内容の生成はブラウザやlibdatachannelが行ってくれる。

手動シグナリング

WebRTCの接続は "手動" でも行える。要するに人間自身が通信路となって通信に必要なデータをコピペによって伝達してやれば良い。

https://html5experts.jp/mganeko/19814/

この業界では、このような交換コミュニケーションを シグナリング (signaling)と呼ぶ。当然、通常のWebRTCではユーザー様にそんなことさせられないので、WebSocketなり何なりの機構で自動的にシグナリングを行うのが慣例となっている。

WebSocketや fetch() は使えない

単純に考えるとデバイスに対して直接WebSocket接続を張ってしまえば十分に思える。しかし現実には、 mixed content制約 によりhttpsサイトからhttpサイトに対して直接的に通信することはできない。このため、素のhttpサーバーしか持たないデバイスに対して自動的なシグナリングを実現するには一工夫必要となる。

もちろん、いわゆるオレオレ証明書をデバイスに持たせるといった方法で正当なhttpsサーバーをデバイス側に用意する方法もあるが、今回は除外している。後の節で紹介する Plexの手法 は正当なhttpsサーバーをユーザー側に用意する手法となっている。

location.hash を悪用してサイト間通信する

... というわけで古代のOAuth2におけるimplicit flowでお馴染みだったURLのフラグメント部を使用した通信、つまり、URLにおける # 以降の部分を使用した通信を実装する。この方式はmixed content制約の影響を受けず、httpsサイトとhttpサイトの自動的な通信を実現できる。

単純にimplicit flowを使うだけだとページ遷移が発生してしまうためWebRTCのピアを維持できない。そこで、Webアプリから 通信用Window を開いて新しい閲覧コンテキストを確保し、更に localStorage APIを使用して閲覧コンテキスト間で通信することにする。

  1. Webアプリは <a> タグを target="_blank"rel="opener"href="#<セッション名> の属性入りで生成する
  2. ユーザは生成されたリンクをクリックして通信用Windowを開く
  3. 通信用Windowは localStorage を監視することでWebアプリからの通信リクエストを待機する
  4. 通信リクエストが来たら、 window.location.hrefhttp://<デバイスのIPアドレス>/#<通信内容> に書き換える
  5. デバイス側のWebページからデバイスに対して fetch() で通信する -- Fetch APIにはsecure contextは必要ない
  6. デバイス側のWebページが window.location.hrefhttps://<通信用Window>/#<通信結果> に書き換える
  7. 通信用Windowは結果を localStorage に書き込んで Webアプリに通知
  8. 通信用Windowは3.に戻るか、通信が完了したら 自分自身を window.close() で閉じる -- このために rel="opener" が必要

通信用WindowはWebアプリのサイトで提供されるhttpsのページ(localStorage にアクセスする用)と、デバイスが提供するhttpのページ(デバイスのAPIを fetch() でアクセスする用)を往復しながら処理することになる。これらは異なるOriginを持つため、基本的にデータを共有できないが、URLのフラグメント部にステートを持たせる形で無理矢理データを共有できる。

実際のWebRTCシグナリングは、上記の 5 、 fetch() API でデバイスに POST する ことで行う。

今回の実装では、通信データをURLのフラグメント部に載せるために、データをJWEで暗号化またはJWSで署名し、base64urlエンコードしている(これはOpenID Connectで使用されるJWTと同じ表現となっている)。遷移を window.location.href = <URL> の形で行ったので、履歴には実際のやりとり履歴が残り、履歴のURLに通信した内容が残っているのを確認できる。... URLにそんなに大量な情報を載せてしまって大丈夫なのかというのはあるが、どうやらIEや旧Edgeを無視すれば50KB程度は行けるようだ。

https://stackoverflow.com/questions/16247162/max-size-of-location-hash-in-browser

また、いわゆるモバイルブラウザではポップアップの表示に難があるため、今回の実装ではユーザー自身にリンクをタップさせて実際の接続処理を行っている。WebRTCの接続を確保し終えたら通信用Windowは不要となるため自動的に閉じるようにしている。 window.close() は呼出しにセキュリティ制約があるため、 rel="opener" を付けた。

この手順で、Webアプリと 素の(insecureな)HTTPサーバーしか持たないデバイスの間で自由なデータを交換することができ、今回はそれをシグナリングに使うことでWebRTC通信を確立できる。

セキュリティ

今回の場合シグナリングのパフォーマンスは大して問題にならないので、httpやアドレスバー経由でやりとりするメッセージは常に署名(JWS)または暗号化(JWE)して対応した。WebRTC通信が安全であるためには、シグナリングでやりとりする内容も安全でなければならない(ただし、プライバシーの問題を除けば、シグナリング通信が暗号化されている必要はない)。

技術的には、実際のWebRTC接続でやりとりする通信はDTLSやSRTPで常に暗号化されており、シグナリングでやりとりされる内容には、それらで使用する鍵を含んだ(一時的な)証明書のフィンガープリントが含まれている。

また、シグナリングでやりとりするデータの署名や暗号化のために、デバイスはユーザーに何らかの 安全な 方法で公開鍵を渡す必要がある。Webアプリはデバイスから取得したメッセージの署名を常に公開鍵で検証することで、偽のデバイスには接続しない事を保証できる。不正な公開鍵をWebアプリに設定したり、デバイス側の秘密鍵が危殆化した場合は、最悪接続がMan-in-the-Middleされてしまう可能性がある。

デバイスが提供する通信用Webページはhttpで転送されるため、信頼性のないネットワークでは他のページに差し替えられてしまう危険がある。特に、今回は rel="opener" によって通信用Windowに opener を残しているので、接続元のWebアプリも任意のURLに遷移してしまう可能性がある。要はhttpsでないリンクを踏むことによる一般的なセキュリティリスクは避けられない。... まぁ普通にWebブラウズしても広告とかで無関係のページに飛ばされる可能性はあるわけだから全然問題ないな!

今回の手法は、デバイスから見たときにユーザーが本物かどうかを判断する方法は一切提供しない。これが必要なら、確立されたWebRTC接続上で追加の認証を行う必要がある。

そもそも何故これが許されているのか問題

重要な疑問は、mixed content制約を強制するWebブラウザにおいて、 素のHTTPしか喋らないデバイスとの直接接続をWebRTCが許容しているのは何故か というポイントだろう。

WebRTCの接続自体には明示的な許可は不要であるため、原理上は↑の手順に従ってちょっとポップアップをパカパカするだけでinsecureなサーバーに接続でき、(WebSocket同様の)任意のデータをリアルタイムかつ双方向にやりとりできる強力な通信路を確保できる。

https/WebSockets での制約

この手法はWebSocketには存在した、デフォルトで有効になっているセキュリティ制約を迂回してしまう:

ブラウザの制約: 通常、WebブラウザはhttpsサイトからSSL化されていないWebSocket (ws://) への接続を禁止している。ブラウザはこの制約を無効化できる(e.g. Firefoxにおける about:confignetwork.websocket.allowInsecureFromHTTPS オプション)ものの、デフォルトでは禁止されている。

CORS制約: 常識的なサイトはCORSによって必要最低限のホスト名のみをアクセス可能なドメインに指定している。この指定は WebSocket の接続先にも適用されるため、接続先が正当なSSL証明書を持っていたとしてもCORSで許可されない限りは接続されない。

これらに比べると、WebRTCのシグナリングデータが信頼できるソースに由来することを強制する仕組みは何もない。

RFC8826 Security Considerations for WebRTCRFC8827 WebRTC Security Architecture では、以下のようなモデルに言及している:

                          +----------------+
                          |                |
                          |   Web Server   |
                          |                |
                          +----------------+
                              ^        ^
                             /          \
                    HTTPS   /            \   HTTPS
                      or   /              \   or
               WebSockets /                \ WebSockets
                         v                  v
                      JS API              JS API
                +-----------+            +-----------+
                |           |    Media   |           |
                |  Browser  |<---------->|  Browser  |
                |           |            |           |
                +-----------+            +-----------+
                    Alice                     Bob

Figure 1: A Simple WebRTC System

WebブラウザにはSame Origin Policy(SOP) -- インタラクティブなコンテンツがブラウザのURLバーに表示されるドメインに信頼されていることを保証する -- があるが、httpsにせよWebSocketにせよ現代的なSOPの実現はCORSに依存しており、素のhttpを経由してシグナリングしたWebRTCにはこれが効かないことになる。

ユーザー自身がサイトを信頼する必要がある

ただし、今回のシグナリング手法は閲覧コンテキストを2つ必要とし、うち片方は window.location を操作できなければならない。そのためには window.open() なりなんなりの手法で閲覧コンテキストを新たに用意しなければならず、このタイミングではuser gestureや明示的なポップアップの許可が必要になることが多い。不正な目的でこれを活用したければ、もっと間接的な手法が必要になるだろう。

そもそも httpsで提供されていることがサイトの安全性/信憑性を保証するわけではない 。いわゆるフィッシング詐欺ページだって今や殆どはhttpsで提供されているし、ユーザー自身がサイトが正当なものかどうかを判断しなければならないのはhttpsでも同様と言える。mixed content制約にせよSOPにせよこれらは安全機構の一部に過ぎず、一般に、ユーザーはサイト上でアクションを起こす前によく考える必要がある。

もっとも、今回の手法を採用するにあたって、ユーザーに対してどのようにリスクを説明するかというのは難しい問題と言える。 "IPアドレスが接続先のデバイスを指していない場合は接続先から何かされる可能性があります" と書いたところで納得できるだろうか。

WebRTCのセキュリティに関する良い文書 https://webrtc-security.github.io/report_ja/ では以下のように説明されている:

どちらのケースでも、最初の認証の段階によって、 異なるオリジンからの任意のデータ転送を阻止できる。

今回の手法は、ここで言うところの "最初の認証の段階" がデバイスの公開鍵しか存在せず、しかもその認証がWebアプリ内で完結しているため、ユーザーはWebアプリ(を提供しているhttpsサイト)を信頼するしかないというのがポイントとなる。

応用を考える

この手法の重要なポイントは、 正当なSSL証明書を持たないデバイスでもWebブラウザ上のsecure contextを要求する機能性を活用できる点 にある。例えば以下のような応用が考えられる:

(Webアプリがsecure contextを要求しないならあまりメリットは無い。。しかし、WebRTCを使ったりWebAssemblyでスレッドを作ったりするにはsecure contextが結局必須になるため、それなりに需要は有るのではないだろうか。。)

WebアプリからのVPNアクセス

既にLeaning technologyのWebVM https://leaningtech.com/webvm/ では TailscaleのWebSocket接続機能を使用してVPNアクセスを実現 している。ただし、この機能はTailscale側のDERPサーバーを使用しているので、使えば使うほどTailscaleにトラフィック代を払わせることになる。

今回のようなWebRTC直接接続が実現できれば、VPNアクセスポイントが追加でhttpサーバーとWebRTC機能を提供するだけで同様のことが可能になる。

... いやまぁ常識的なVPNソリューションはユーザー認証のために自社のサーバーを持っているわけだから、そこでシグナリングすれば良いだけだけど。。そのようなソリューションを採用したとしても、トラフィックの大半をWebRTCに迂回させられる。

家庭内NASへの直接アクセス

NASサーバーが今回のようなシグナリングを実装すれば、WebアプリからIPアドレス(と、鍵)を直接指定したアクセスを実現できる。

大手の家庭用NASベンダは自社のクラウドサービスも同時に提供しているので、シグナリングはそこで行えば良いかもしれない。しかし、NASを自作する向きには意味があるのではないだろうか。

Webアプリのp2p配信

デバイスとブラウザ間で接続したdatachannel上でhttpを流し、それをservice workerで中継してやることで、Webアプリをp2pで配信することが可能になると考えられる。

現状ではsecure contextが必要なWebアプリの開発やテストは、ngrokなり何なりの方法で、一旦正当なSSL証明書を持った外部サーバーを経由する方法で行われることが多い。これだと大量のデータを必要とするWebGLゲームのようなWebアプリのデバッグが辛いので、p2p配信によって効率化できる。

もっとも、Safari以外の多くのブラウザでは localhost もsecure contextと見做されるので、WebRTCを経由する必要があまりない。Safariでは localhost はsecure contextでなく、また Brave のように localhost へのアクセスをallow list運用しているブラウザもある。

https://github.com/brave/adblock-lists/blob/0f16976b51f999ab374ee118c8baafb0a9ec0507/brave-lists/localhost-permission-allow-list.txt

localhost でさえ扱いが安定していない事を考えると、今回のような手法で間接的にローカルデバイスからWebアプリを配信できるとすれば一定の需要がある気はする。特にモバイルデバイスでテストするようなケースで便利なのではないだろうか。

代替案

これまでに言及しているように、通常はそもそもシグナリングを外部の(正当なSSL証明書を持った)サーバーに依存することが選択される。ただシグナリングサーバーの運用にはコストが掛かるはずなので運用コストをゼロにすることはできない。

手動 / 別のトランスポート

そもそも、別の方法でシグナリングのメッセージを交換しても良い。

例えば、デバイスにディスプレイとカメラがあるならシグナリングのメッセージをQRコードにして交換するといった方法が考えられるし、ChromeであればWeb Bluetoothのような選択肢もある。最悪、ユーザーに呪文(SDPのセッション文字列)を直接コピペさせても良いだろう。

ただ、個人的には今回の手法の方がエンドユーザーには優しい気はしている。Webブラウザで完結するのは強い。

Plexの手法

Plexはワイルドカード証明書の秘密鍵をエンドユーザーに渡し、これと任意のアドレスに解決するDNSサーバーを組み合わせることで、明示的な証明書管理無しでエンドユーザーに正当なSSL証明書を配付している。

https://words.filippo.io/how-plex-is-doing-https-for-all-its-users/

これを使用することで、ローカルネットワーク上のデバイスは正当なhttpsサイトになれるので、今回のような面倒な手法を使わなくても直接WebSocketやWebTransportで通信できるようになる。

ただ、Plexの手法はエンドユーザーのデバイス上で証明書が危殆化すると大変なことになるため、今回の手法の方が通信路の面では少しだけ安全と言える。もっとも、Plexの手法はoriginを完全に制御できるため、コンテナとなるhttpsサイトが必要になる今回の手法よりも全体的にはずっと安全と考えられる。

Plexの実装はユーザーが用意してきた証明書に署名する必要はあるので、ユーザー毎のワンタイムコストは掛かることになる。

かんそう

EDIT:

https://twitter.com/voluntas/status/1707383060130648305

確かに単にlibdatachannelが遅い可能性は有りますね。。実ブラウザやlibwebrtcやPionと差し替えて比較すると興味深そう。ただ、広帯域な通信を行う場合はどうしてもブラウザ側のJavaScript APIが壁として残ると思っています(メッセージサイズ上限が小さく、ストリームAPIが無い)。


個人的には WebRTCのdataChannelさえ効率的なら 、今回の素HTTPシグナリング自体はかなり魅力的なソリューションなのではないかと思える。Webアプリとして静的なhttpsサイトさえ配信できれば、完全に追加コスト無しでp2p通信を実現でき、それにはさまざまな応用例が考えられる。ただ、残念ながら現実のブラウザにおけるdataChannel実装はあまり効率的でなく、あくまでサイドチャネル的な用法に限定されているように見える。

https://zenn.dev/okuoku/scraps/dde8b98c957965

ローカルで実験してみたところ、ChromeやFirefoxはCPUを100%占有して25MiB/sec程度だった。

それでもローカルネットワークへのアクセスくらいなら実用的なパフォーマンスが出るため、Webブラウザでの操作インターフェースを提供するデバイスの新たな接続手法や、p2pのWebアプリ配信手法として意味があるのではないかと思う。

セキュリティ分析

どうせローカルで動かすものだし。。ということで、セキュリティについてはあまり熱心に考察していない。例えば現状のプロトコルはリプレイ攻撃に脆弱であり、何らかの方法でWebRTCセッション上のパラメタを推測されると問題が起こる可能性がある。

また、そもそもこのようなWebRTCシグナリングが許されている理由もスッキリしていない。通常のブラウザ機能と比べて、 WebRTCはちょっとセキュリティ的に優遇され過ぎている のではないだろうか。。いわゆるリアルタイム配信の送受信についてはWebTransportでも行えるため、今後WebRTCが本質的に必要なのはユーザー同士のp2pに収束していくと考えられる。そのタイミングでWebRTCの接続自体に明示的な許可を要求しても良い気がする。どうせカメラやオーディオを使うためには許可が必要なわけだし。

location 書き換えによる遷移がユーザーのリンククリックにかなり近いコンテキストを持っていることも問題に思える。Secure contextを持たないサイトから "自動的に" CORS対象でないoriginに遷移を行った場合は、遷移先がsecureであってもsecure contextを剥奪しても良いのではないだろうか。

"コンテナサイト"

現代的な環境では静的なhttpsサイトを保有するコストは殆どゼロに近いため、この仕組み自体 -- つまり、WebRTCを開始するサイト -- をホストするWebサイトが存在することは前提にしてしまっている。

このようなサイトを"コンテナサイト"と呼ぶことを考える。

現在でも、ユーザー提供のアプリをサーバ側にホストする形でのコンテナサイトが色々存在する。

https://zenn.dev/okuoku/scraps/11478022816b86

  • JavaScriptアプリ: codepen.io 、 Glitch 、...
  • ゲーム特化: UnityRoom、GodotPlayer、 Plicy 、 ...

特にPlicyは興味深い実装で、既存のゲームエンジン向けのランタイムをサイトでホストし、MIDI楽曲の再生といった機能性を追加で実現している。

今回の手法でローカルネットワークの資源にアクセスできるようにすることは、コンテナサイトにとっては魅力的な機能になるかもしれない。しかし↑で挙げたような応用例のうち、Webアプリをp2p配信するようなケースでは、自身のWeb originをどのようにして保護するのかが課題となる。

逆に、例えば配信するアプリをWebAssemblyアプリに限定する等、何らかの方法でのサンドボックス化が行えるならば、コンテナサイトはもっと柔軟な機能性を実現できる可能性がある。

プラットフォーマーのモチベーション

そもそも大手のプラットフォーマーはユーザー同士をp2p通信させるモチベーションが殆どない(ユーザー同士のコミュニケーションに時間を取られるよりも、ユーザーに不快広告を見せ続ける方が儲かる)ため、ブラウザにp2p機能が載っている現状がどの程度続くのかも不透明と言える。

プラットフォーマーのモチベーションを維持するためには、あんまり変な活用を発明しない方が望ましいのかもしれない。現状では考えられないが、大昔のWebはActiveXで事実上何でもできた。それと同様に、自由なp2p接続がWebブラウザで可能な時代も過去のものになる可能性がある。

通常のケースでは、インターネット上の発言権とは "ドメインおよび正当なSSL証明書を保有していて、かつ、IPv4グローバルアドレスを持っていること" または、そのような条件を整えているベンダに金を払うことと規定される。そうしないとsecure contextが必要なAPIの使用権が与えられない。今回のような、それをbreakするテクニックの存在をプラットフォーマーは許してくれるだろうか。

Discussion