Discordがボイスチャンネル向けに導入したE2EEプロトコルDAVEについて、ボットとしてMLSグループへの参加を成立させ、他参加者の音声をRTP受信からOpusデコードまでSwiftネイティブで復号する検証をしました。公式ドキュメントには明記されていない、rtpsize拡張ヘッダの暗号化範囲・PrepareEpochによるグループ再初期化・IP Discoveryのバイト順という、受信側の実装でしか見えない3つの落とし穴を扱います。
DAVEとMLS
Discordは2024年9月に、音声・映像通話のエンドツーエンド暗号化プロトコル「DAVE」を発表しました。DAVEのプロトコル白書自体は「MLSプロトコル自体の説明はしない、それはRFC 9420が担う範囲」と明記しているとおり、鍵合意の仕様は MLS (Messaging Layer Security) 側にあります。DAVEはこのMLSのグループ鍵から、通話参加者だけが持つ送信者ごとのラチェット鍵を導出して、メディアフレームを暗号化します。公式ドキュメントは、ダイレクトメッセージ・グループDM・ボイスチャンネル・Go Liveストリームを含むすべての音声・映像通話について、2026年3月1日以降はE2EEのみをサポートすると明記しています。
DiscordはプロトコルのC++実装 libdave をMITライセンスで公開しています。今回はこれをSwiftから直接呼び出し、ボイスチャンネルにボットとして参加してDAVEのグループ参加を成立させたうえで、他参加者の音声をRTPで受信し、トランスポート復号、DAVE復号、Opusデコードを経て話者ごとのWAVファイルに書き出すところまでを実装しました。送信(自分から音声を流す側)は対象外で、受信だけに絞ったPoCです。
この記事で扱う3つの落とし穴が、この経路のどこに現れるかを先に示しておきます。
libdaveのC APIをSwiftから直接叩く
libdaveをSwiftから呼べること自体は、前回の記事で骨格だけを確認しています。セッションの作成・初期化・破棄が通ることまでを見た段階で、実サーバーへの接続やMLSの実データのやり取りは手つかずでした。今回はその続きにあたります。
ブログDiscordのE2E暗号化DAVEをSwiftから使うためlibdaveのC APIを直接呼び出したDiscordのE2E暗号化プロトコルDAVEに、Swiftから対応できるかを検証しました。Discord公式のC++実装libdaveはフラットなC APIを公開しているため、Swift/C++ interopの実験的機能を使わず、標準のC interopだけでセッションの作成から破棄まで動かせることを確認しています。libdaveはフラットなC APIとして公開されているので、SwiftPMの systemLibrary でモジュールマップを書けば、実験的なC++相互運用を使わずに通常のC interopで呼び出せます。py-cordやdiscord.jsのようなランタイムは介さず、暗号処理はCryptoKitとlibdaveだけで完結します。ラッパーの骨格は次のようになります(簡略化しています)。
final class DaveSession {
let handle: DAVESessionHandle
init?(authSessionId: String) {
guard let handle = daveSessionCreate(nil, authSessionId, failureCallback, userData) else {
return nil
}
self.handle = handle
}
func processCommit(_ commit: [UInt8]) -> CommitOutcome? {
// MLSのCommitメッセージを処理し、ロスター更新を返す
}
}
トランスポート層の暗号方式には aead_aes256_gcm_rtpsize を選びました。Discordのドキュメントは音声ゲートウェイが対応するモードとして aead_xchacha20_poly1305_rtpsize を必須と説明する一方、aead_aes256_gcm_rtpsize は利用可能なら優先すべきモードと位置づけています。CryptoKitはAES-GCMをネイティブでサポートしているため、こちらを選ぶとハンドシェイクだけでなく受信メディアの復号でも同じ実装を使い回せます。
rtpsizeの拡張ヘッダ、暗号化されているのは値の部分だけ
最初につまずいたのがここです。受信したRTPパケットには拡張ヘッダ(先頭バイトが 0x90 でXビットが立っている)が付いていて、AEADの認証対象データ(AAD)がどこまでかを正しく決めないと復号に失敗します。
Discordのドキュメントは、rtpsizeモードの「暗号化されていないヘッダ」の範囲をSRTPの考え方に沿って説明し、RTP基本ヘッダ(CSRCを含む)と拡張プリアンブルまでを平文のまま認証されるAADとして扱うとしています。ただし、拡張ヘッダの値そのものが暗号化されているかどうかまでは明記していません。実際には、拡張ヘッダのうちAADに含まれるのはプリアンブル4バイトだけで、値の部分は暗号文に含まれています。復号後、暗号文の先頭からこの拡張値を取り除く処理が必要で、送信専用のライブラリでは扱わない部分のため、受信側の実装(Pythonの discord-ext-voice-recv のような)を読んで初めて仕様の全体像が見えてきます。
PrepareEpochによるグループ再初期化
2つ目の落とし穴は退出処理です。DAVEのプロトコル仕様には、グループの参加者が1人まで減ったときの挙動が定義されています。
When only a single member remains in an established group, the voice gateway sends a
dave_protocol_prepare_epochopcode (24) with the initial group epochepoch = 1to the sole remaining member.
プロトコル仕様によれば、これはMemberを取り除く通常のRemove proposalではなく、epoch=1の PrepareEpoch によるグループの作り直しです。受信した側は新しいキーパッケージを生成して送り直す必要があります。
仕様には「この作り直しが発生する」ことは書かれていますが、実装側が「DAVEの初期化処理を一度きりのガードにしてはいけない」という含意までは明示されません。最後の1人になって全員が抜け、また別の参加者が入ってくるたびにこのサイクルが繰り返されるため、初期化処理を再入可能にしておかないと、2回目のグループ参加が失敗します。
IP Discoveryのバイト順
3つ目は、UDP接続確立時のIP Discoveryレスポンスです。Discordのドキュメントは「numericな値はすべてbig-endian」と明記していて、ここ自体は仕様上は明確です。それでも実装時には見落としやすいポイントです。手元でリトルエンディアンとして読み違えて試したところ、ポート番号自体は誤った値になるだけで、サーバー側が実際の送信元アドレスでルーティングするため接続自体は成立しました。エラーで気づけるとは限らないという意味で見落としやすく、正しく受信するにはここをドキュメントどおりbig-endianとして読む必要があります。
受信専用のPoCとして持つ範囲
今回のPoCは受信専用です。ボット側から音声を送り返す実装、48kHzから16kHzへのリサンプリング、libdaveやlibopusといったネイティブ依存をCI上でビルドする仕組みは、スコープの外に置いています。Discordの音声受信は公式にドキュメント化されていない領域で、これは自分が管理するサーバーに対して行った技術検証です。実際の通話を録音することは、参加者の同意や法的な扱いが関わる場合があるため、実運用に持ち込む前にそこは別に整理する必要があります。
