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 種可能的通用架構可以這樣做:

由於 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 最困難的部分是在網絡條件不斷變化的同時將正確的視頻分辨率轉發給每個呼叫參與者。

這個困難是以下基本問題的組合:

解決方案是結合幾種我們將單獨討論的技術:

聯播(Simulcast)和數據包重寫(Packet Rewriting)

爲了讓 SFU 能夠在不同分辨率之間切換,每個參與者必須同時向 SFU 發送許多層(layers,分辨率),這就叫聯播(Simulcast),俗稱大小流。它是 WebRtc 中的一個概念。

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..={
  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;
        }
      }
    }
  }
}

整合

通過結合這三種技術,就有一個完整的解決方案:

結果是每個參與者都可以在給定當前網絡條件的情況下以最佳方式查看所有其他參與者,並且與端到端加密兼容。

端到端加密

說到端到端加密,值得簡要描述它的工作原理。因爲它對服務器完全不透明,所以它的代碼不在服務器中,而是在客戶端中。特別是,我們的實現存在於 RingRTC[8],一個用 Rust 編寫的開源視頻通話庫。

每個幀的內容在被分成數據包之前都經過加密,類似於 SFrame[9]。有趣的部分實際上是密鑰分發和輪換機制,它必須對以下場景具有魯棒性:

爲了保證這些屬性,我們使用以下規則:

使用這些規則,每個客戶端都可以控制自己的密鑰分配和輪換,並根據呼叫中的人而不是受邀參加呼叫的人來輪換密鑰。這意味着每個客戶端都可以驗證上述安全屬性是否得到保證。

小結

在該文章相關的 Reddit 討論貼 [10] 中,該庫作者表示:

  1. 很喜歡用 Rust 來寫這個項目,並且認爲 Rust 是實現這類項目最好的語言。

  2. 關於優化性能有兩點努力:

  1. 認爲服務器的所有主要邏輯對性能的影響幾乎沒有一般的 “通過服務器推送大量數據包” 那麼重要。

  2. 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