Signal - 如何用 Rust 構建大規模端到端加密通話
原文:https://signal.org/blog/how-to-build-encrypted-group-calls/[1]
前言
本文不是對原文的完整翻譯,只是關鍵摘要。
Signal 是由 Signal 技術基金會和 Signal Messenger LLC 開發的跨平臺加密消息服務。Signal 經互聯網發送一對一及組羣消息,消息可包含圖像及視頻,它還可以用來經互聯網作一對一及組羣語音通話。非營利組織 Signal 基金會於 2018 年 2 月成立, Brian Acton 提供了初期 5000 萬美元之初始資金。自 2021 年 1 月,Signal 已獲得超過 1 億 5 百萬之下載總量,並且該軟件擁有約 4 千萬月活躍用戶。截止 2021-2-14,Signal 已被安裝於超過 5 千萬臺 android 設備上。2021 年因爲 WhatsApp 修改隱私條款,馬斯克呼籲大衆使用 Signal 。
Signal 依賴於由 Signal Messenger 維護的集中式服務器。除了路由 Signal 的消息外,服務器還有助於發現同時也是 Signal 用戶註冊的聯繫人以及自動交換用戶的公共密鑰。默認情況下,Signal 的語音和視頻通話是雙方的直接連接。如果調用者不在接收者的通訊簿中,則將調用路由通過服務器以隱藏用戶的 IP 地址。
Signal 內部也大量使用 Rust 語言,並且開源 [2] 了一些服務和組件庫。其中包括了 Signal-Calling-Service[3] 調用服務,該服務幫助 Signal 支持端到端加密羣組通話規模擴展到 40 人。本文就是闡述了 Signal-Calling-Service 的原理。
開源調用服務 Signal-Calling-Service 原理
選擇性轉發單元 (SFU,Selective Forwarding Units)
在羣組通話中,每一方都需要將他們的音頻和視頻發送給通話中的所有其他參與者。有 3 種可能的通用架構可以這樣做:
-
全網(Full mesh):每個呼叫參與者將其媒體數據(音頻和視頻)直接發送給其他呼叫參與者。這隻適用於小規模的呼叫,但不適用於參與者多的情況。大多數人的網絡連接速度不夠快,無法同時發送 40 個視頻副本。
-
服務器混合(Server mixing):每個呼叫參與者將其媒體數據發送到服務器。服務器將媒體 “混合” 在一起並將其發送給每個參與者。這適用於許多參與者,但與端到端加密不兼容,因爲它要求服務器能夠查看和更改媒體數據。
-
選擇性轉發(Selective Forwarding):每個參與者將其媒體發送到服務器。服務器將媒體 “轉發” 給其他參與者而不查看或更改它。這適用於許多參與者,並且與端到端加密兼容。
由於 Signal 必須具有端到端加密並擴展到許多參與者,因此使用選擇性轉發。執行選擇性轉發的服務器通常稱爲選擇性轉發單元或 SFU。
SFU 的主循環邏輯 Rust 代碼簡化版爲:
let socket = std::net::UdpSocket::bind(config.server_addr);
let mut clients = ...; // changes over time as clients join and leave
loop {
let mut incoming_buffer = [0u8; 1500];
let (incoming_size, sender_addr) = socket.recv_from(&mut incoming_buffer);
let incoming_packet = &incoming_buffer[..incoming_size];
for receiver in &clients {
// Don't send to yourself
if sender_addr != receiver.addr {
// Rewriting the packet is needed for reasons we'll describe later.
let outgoing_packet = rewrite_packet(incoming_packet, receiver);
socket.send_to(&outgoing_packet, receiver.addr);
}
}
}
實際上 Signal 團隊考察了很多開源的 SFU 實現,但是符合擁塞控制(congestion control)的只有兩個,然而還需要大量修改才最多支持 8 個參與者。因此,他們才決定用 Rust 重新實現一個新的 SFU。目前,它已經爲 Signal 線上服務九個月,可以輕鬆擴展到 40 個參與者(未來會更多)。可以作爲基於 WebRTC 協議實現 SFU 的參考實現,代碼可讀性非常高。並且借鑑了 googcc[4] 的擁塞控制算法。
SFU 最困難的部分
SFU 最困難的部分是在網絡條件不斷變化的同時將正確的視頻分辨率轉發給每個呼叫參與者。
這個困難是以下基本問題的組合:
-
每個參與者的網絡連接容量都在不斷變化並且很難知道。如果 SFU 發送過多,則會造成額外的延遲。如果 SFU 發送的太少,質量就會很低。因此,SFU 必須不斷仔細地調整它發送給每個參與者的數量,以使其 “恰到好處”。
-
SFU 不能修改它轉發的媒體數據。要調整它發送的數量,它必須從發送給它的媒體數據中進行選擇。如果可供選擇的 “選項” 僅限於發送可用的最高分辨率或根本不發送,則很難適應各種網絡條件。因此,每個參與者必須向 SFU 發送多種分辨率的視頻,並且 SFU 必須不斷小心地在它們之間切換。
解決方案是結合幾種我們將單獨討論的技術:
-
聯播(Simulcast)和數據包重寫(Packet Rewriting)允許在不同的視頻分辨率之間切換。
-
擁塞控制(Congestion Control)確定要發送的正確數量。
-
速率分配(Rate Allocation)決定在該預算內發送什麼內容。
聯播(Simulcast)和數據包重寫(Packet Rewriting)
爲了讓 SFU 能夠在不同分辨率之間切換,每個參與者必須同時向 SFU 發送許多層(layers,分辨率),這就叫聯播(Simulcast),俗稱大小流。它是 WebRtc 中的一個概念。
-
上行一般是三路流,按分辨率和碼率,一般分爲 fhq(大中小) 三層
-
下行可以分給不同的用戶不同的流,比如網不好時分發個小流 q,網變好了再切回大流 f
SFU 與視頻流服務器不同,它不存儲任何內容,它的轉發必須是即時的,它通過稱爲數據包重寫的過程來實現。
數據包重寫是更改媒體數據包中包含的時間戳、序列號和類似 ID 的過程,這些 ID 指示數據包在媒體時間線上的位置。它將來自許多獨立媒體時間線(每層一個)的數據包轉換爲一個統一的媒體時間線(一層)。
數據包重寫與端到端加密兼容,因爲在端到端加密應用於媒體數據之後,發送參與者將重寫的 ID 和時間戳添加到數據包中。這類似於使用 TLS 時加密後如何將 TCP 序列號和時間戳添加到數據包中。
擁塞控制
擁塞控制是一種確定通過網絡發送多少的機制:不要太多也不要太少。它的歷史悠久,主要是 TCP 的擁塞控制形式。不幸的是,TCP 的擁塞控制算法通常不適用於視頻通話,因爲它們往往會導致延遲增加,從而導致通話體驗不佳(有時稱爲 “滯後”)。爲了爲視頻通話提供良好的擁塞控制,WebRTC 團隊創建了 googcc[5] ,這是一種擁塞控制算法,可以確定正確的發送量,而不會導致延遲大幅增加。
擁塞控制機制通常依賴於某種從包接收方發送到包發送方的反饋機制。googcc 旨在與 transport-cc[6] 一起使用,該協議中接收方將定期消息發送回發送方,例如,“我在時間 Z1 收到數據包 X1;在時間 Z2 的數據包 X2,……”。然後發送方將這些信息與自己的時間戳結合起來,例如,“我在 Y1 時間發送了數據包 X1,它在 Z1 被接收到;我在時間 Y2 發送了數據包 X2,然後在 Z2 收到了它……”。
在 Signal Calling Service 中,以流處理的形式實現了 googcc 和 transport-cc。流管道的輸入是上述關於數據包何時發送和接收的數據,我們稱之爲 acks[7]。管道的輸出是應該通過網絡發送多少的變化,稱之爲目標發送速率。
流程的前幾步是繪圖確認延遲與時間的關係圖,然後計算斜率以確定延遲是增加、減少還是穩定。最後一步根據當前的斜率決定要做什麼。代碼的簡化版本如下所示:
let mut target_send_rate = config.initial_target_send_rate;
for direction in delay_directions {
match direction {
DelayDirection::Decreasing => {
// While the delay is decreasing, hold the target rate to let the queues drain.
}
DelayDirection::Steady => {
// While delay is steady, increase the target rate.
let increase = ...;
target_send_rate += increase;
yield target_send_rate;
}
DelayDirection::Increasing => {
// If the delay is increasing, decrease the rate.
let decrease = ...;
target_send_rate -= decrease;
yield target_send_rate;
}
}
}
擁塞控制很難,但是現在它基本可以用於視頻通話:
-
發送方選擇一個初始速率並開始發送數據包。
-
接收方發送回有關何時收到數據包的反饋。
-
發送方使用該反饋根據上述規則調整發送速率。
速率分配
現在 SFU 已經知道要發送多少,接下來就必須確定要發送什麼內容(要轉發哪些層),這個過程被稱爲速率分配。
這個過程就像 SFU 從受發送速率預算約束的層菜單中進行選擇。例如,如果每個參與者發送 2 個層,而還有 3 個其他參與者,則菜單上總共有 6 個層。
如果預算足夠大,我們可以發送我們想要的所有內容(直到每個參與者的最大層)。但如果沒有,我們必須優先考慮。爲了幫助確定優先級,每個參與者通過請求最大分辨率來告訴服務器它需要什麼分辨率。使用該信息,我們使用以下規則進行費率分配:
-
大於請求的最大值的層被排除在外。例如,如果只查看小視頻網格,則無需發送每個視頻的高分辨率。
-
較小的層優先於較大的層。例如,最好以低分辨率查看每個人,而不是以高分辨率查看某些人而其他人則根本不查看。
-
較大的請求分辨率優先於較小的請求分辨率。例如,一旦可以看到所有人,那麼認爲最大的視頻將在其他視頻之前以更高的質量填充。
代碼的簡化版本如下所示:
// The input: a menu of video options.
// Each has a set of layers to choose from and a requested maximum resolution.
let videos = ...;
// The output: for each video above, which layer to forward, if any
let mut allocated_by_id = HashMap::new();
let mut allocated_rate = 0;
// Biggest first
videos.sort_by_key(|video| Reverse(video.requested_height));
// Lowest layers for each before the higher layer for any
for layer_index in 0..=2 {
for video in &videos {
if video.requested_height > 0 {
// The first layer which is "big enough", or the biggest layer if none are.
let requested_layer_index = video.layers.iter().position(
|layer| layer.height >= video.requested_height).unwrap_or(video.layers.size()-1)
if layer_index <= requested_layer_index {
let layer = &video.layers[layer_index];
let (_, allocated_layer_rate) = allocated_by_id.get(&video.id).unwrap_or_default();
let increased_rate = allocated_rate + layer.rate - allocated_layer_rate;
if increased_rate < target_send_rate {
allocated_by_id.insert(video.id, (layer_index, layer.rate));
allocated_rate = increased_rate;
}
}
}
}
}
整合
通過結合這三種技術,就有一個完整的解決方案:
-
SFU 使用 googcc 和 transport-cc 來確定它應該向每個參與者發送多少。
-
SFU 使用速率分配來選擇要轉發的視頻分辨率(層)。
-
SFU 將多個層的數據包重寫爲每個視頻流的一層。
結果是每個參與者都可以在給定當前網絡條件的情況下以最佳方式查看所有其他參與者,並且與端到端加密兼容。
端到端加密
說到端到端加密,值得簡要描述它的工作原理。因爲它對服務器完全不透明,所以它的代碼不在服務器中,而是在客戶端中。特別是,我們的實現存在於 RingRTC[8],一個用 Rust 編寫的開源視頻通話庫。
每個幀的內容在被分成數據包之前都經過加密,類似於 SFrame[9]。有趣的部分實際上是密鑰分發和輪換機制,它必須對以下場景具有魯棒性:
-
未加入呼叫的人必須無法解密他們加入之前的媒體數據。如果不是這種情況,可以獲取加密媒體數據的人(例如通過破壞 SFU)將能夠在他們加入之前知道呼叫中發生的事情,或者更糟的是,從未加入過。
-
離開通話的人必須無法解密他們離開後的媒體。如果不是這種情況,可以得到加密媒體的人就可以知道他們離開後通話中發生了什麼。
爲了保證這些屬性,我們使用以下規則:
-
當客戶端加入呼叫時,它會生成一個密鑰並通過 Signal 消息(它們本身是端到端加密的)將其發送到呼叫的所有其他客戶端,並在將其發送到 SFU 之前使用該密鑰加密媒體數據。
-
每當任何用戶加入或離開通話時,通話中的每個客戶端都會生成一個新密鑰並將其發送給通話中的所有客戶端。然後它在 3 秒後開始使用該密鑰(允許客戶端有一段時間接收新密鑰)。
使用這些規則,每個客戶端都可以控制自己的密鑰分配和輪換,並根據呼叫中的人而不是受邀參加呼叫的人來輪換密鑰。這意味着每個客戶端都可以驗證上述安全屬性是否得到保證。
小結
在該文章相關的 Reddit 討論貼 [10] 中,該庫作者表示:
-
很喜歡用 Rust 來寫這個項目,並且認爲 Rust 是實現這類項目最好的語言。
-
關於優化性能有兩點努力:
-
通過使用 epoll 和多線程,使讀寫數據包的代碼更快、更併發。
-
將我們的鎖定更改爲更細粒度。
-
認爲服務器的所有主要邏輯對性能的影響幾乎沒有一般的 “通過服務器推送大量數據包” 那麼重要。
-
RingRTC 通過 JNI 將 Rust 代碼整合到 Andriod 平臺。
參考資料
[1]
https://signal.org/blog/how-to-build-encrypted-group-calls/: https://signal.org/blog/how-to-build-encrypted-group-calls/
[2]
開源: https://github.com/signalapp
[3]
Signal-Calling-Service: https://github.com/signalapp/Signal-Calling-Service
[4]
googcc: https://datatracker.ietf.org/doc/html/draft-ietf-rmcat-gcc-02
[5]
googcc: https://datatracker.ietf.org/doc/html/draft-ietf-rmcat-gcc-02
[6]
transport-cc: https://datatracker.ietf.org/doc/html/draft-holmer-rmcat-transport-wide-cc-extensions-01
[7]
acks: https://github.com/signalapp/Signal-Calling-Service/blob/v1.3.0/src/transportcc.rs#L60
[8]
RingRTC: https://github.com/signalapp/ringrtc
[9]
SFrame: https://datatracker.ietf.org/wg/sframe/about/
[10]
Reddit 討論貼: https://www.reddit.com/r/rust/comments/rh9w4j/signal_now_supports_group_calls_up_to_40_people/
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/G89On1lLMUrAl1LrflBUNA