DiscordのE2E暗号化プロトコルDAVEに、Swiftから対応できるかを技術検証しました。結論として、Discord公式のC++実装libdaveが公開しているフラットなC APIを、Swift/C++ interopのような実験的機能を使わず、標準のSwiftPM systemLibraryだけでラップして呼び出せることを確認しています。
DAVEプロトコルとエコシステムの現状
DAVE(Discord Audio & Video End-to-End Encryption)は、Discordの音声・映像通話にMLS(Messaging Layer Security)ベースのE2E暗号化を導入するプロトコルです。プロトコル仕様が公開されているほか、Discordは実装としてlibdaveというJS/C++のライブラリ群をオープンソースで提供しています。
Discordの音声機能を独自に実装するサードパーティのライブラリにとって、DAVEへの対応は避けて通れない課題です。主要言語のエコシステムを見ると、Python向けのライブラリは2026年1月にDAVE対応がマージ済み、JavaScript向けのライブラリも接続断や鍵のローテーション周りで報告されていた不具合が解消しており、いずれも一巡した状態です。
Swift向けのDiscordライブラリに至っては、DAVEやE2EEに関するissueを検索してもほとんど見つかりません。これは「対応が難しくて止まっている」のではなく「まだ誰も本気で取り組んでいない」だけなのではないか、という仮説を立てました。他言語が通った道筋がすでに見えている以上、後から取り組む側は試行錯誤の分を省けるはずです。
libdaveはC++だけでなくC APIも公開している
最初の疑問は、C++で書かれたlibdaveをSwiftから呼び出すコストです。SwiftはC++との相互運用機能を持っていますが、テンプレートやstd::variantなど対応していない機能も多く、実験的な位置づけです。
libdaveのソースを確認すると、bindings_capi.cppという形で、C++の内部実装をラップしたフラットなC API(不透明なハンドル方式)がすでに公開されていました。C++ interopの実験的機能を経由する必要はなく、SwiftPMのsystemLibraryターゲットでCヘッダーを取り込む、成熟した標準のC interopだけで届く距離にあるということです。MLSの実装自体も、libdaveの内部でmlspp(Cisco製、BSD-2-Clauseライセンス)に隠蔽されているため、MLSを自前で実装する必要もありません。
層として並べると、Swiftから見えるのはCヘッダーだけで、C++の内部実装やMLSの実装には直接触れない構造です。
最小限のPoCで検証する
検証用に、libdaveのC APIをSwiftから呼べるかどうかだけを確認する、小さなSwiftパッケージを作りました。Package.swiftでは、Cヘッダーを取り込むsystemLibraryターゲットと、それに依存する実行可能ターゲットを定義します。
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "DavePoC",
targets: [
.systemLibrary(name: "CLibDave"),
.executableTarget(
name: "DavePoC",
dependencies: ["CLibDave"],
linkerSettings: [
.unsafeFlags([
"-L", "(libdaveのビルド出力)",
"-ldave", "-lmlspp", "-lhpke", "-lbytes",
"-lmls_ds", "-ltls_syntax", "-lssl", "-lcrypto", "-lc++",
])
]
),
]
)
linkerSettings に並んでいるライブラリの数が、この構成の実態をよく表しています。dave 本体だけでなく、mlspp とその依存(hpke、bytes、mls_ds、tls_syntax)、OpenSSL、C++標準ライブラリまで明示的にリンクする必要があります。C APIで表面が平らになっていても、下にぶら下がっているものは減りません。
CLibDaveターゲットの中身は、libdaveが公開しているC APIのヘッダーファイルと、それをSwiftに公開するためのmodule.modulemapだけです。Swift側のコードは、ライブラリを触るのに必要な最小限の流れだけを書きました。
import CLibDave
let version = daveMaxSupportedProtocolVersion()
guard let session = daveSessionCreate(nil, "poc-auth-session", nil, nil) else {
fatalError("daveSessionCreate returned NULL")
}
daveSessionInit(session, UInt16(version), 12345, "poc-user-id")
print("session protocol version: \(daveSessionGetProtocolVersion(session))")
daveSessionDestroy(session)
バージョンを取る関数が2つあります。daveMaxSupportedProtocolVersion() はセッションを作る前に呼ぶフリー関数で、ライブラリがサポートしている最大バージョンを返します。この値を daveSessionInit() に渡してセッションを初期化し、初期化後は daveSessionGetProtocolVersion(session) でそのセッションのバージョンを取得します。
実際に動かしたところ、この一連の呼び出しがクラッシュなく完走しました。daveSessionCreate() は有効なハンドルを返し、daveSessionInit() で内部のMLSセッション初期化が正常に進行し、daveSessionDestroy() まで正常終了しています。
「Swift/C++ interopの実験的機能を経由せず、libdaveのC APIをそのまま呼び出せる」という仮説は実証できました。オペーク(内部構造を隠した)ハンドル型のライフサイクル管理は、標準のSwift/C interopだけで問題なく動作しています。
まだ検証できていない範囲
今回のPoCで確認できたのは、セッションの作成・初期化・状態取得・破棄という、ライブラリを呼び出す骨格の部分だけです。実際のDiscord Voice Gatewayとの接続、MLSのproposal・commit・welcomeメッセージを使った鍵交換の実データでのやり取り、Opusフレームの暗号化・復号については未検証のまま残っています。
libdaveのC++ビルドはvcpkg経由の依存関係が数百MB規模になるため、CIへの組み込みやビルド時間は実運用に乗せる前に確認します。まずは骨格部分が動くことを確認できたところまでが今回の検証範囲です。
この続きとして、実際にボイスチャンネルへ接続してMLSグループへの参加を成立させ、他参加者の音声をRTP受信からOpusデコードまで復号するところまで進めました。そちらは別記事で扱っています。
ブログDiscordの音声E2EEプロトコルDAVEをSwiftで受信・復号するDiscordがボイスチャンネル向けに導入したE2EEプロトコルDAVEについて、ボットとしてMLSグループに参加し、他参加者の音声をRTP受信からOpusデコードまでSwiftネイティブで復号する検証をしました。公式ドキュメントだけでは埋まらない、受信側実装だけで見える3つの落とし穴を扱います。