WhatsApp 群組上限 1,024 人。Signal 1,000 人。Telegram supergroup 能再多一些,但靠分片硬撐。大多數即時通訊軟體都卡在幾千人這一檔 —— 因為它們的伺服器必須把每條群組訊息分發(fanout)給每個成員:N 個成員就複製 N 份、路由 N 份、推送 N 次。
BlindPost 不是這樣。對我們來說,發一條群組訊息更像發佈一篇文章,而不是寄信。作者發佈一次,任何有這篇「文章」的地址、又拿到「鑰匙」的人,都可以來讀。閱讀人數沒有理論上限 —— 因為「發佈」這一步不會因為讀者變多而變難。
我們的伺服器就是存這篇加密的「文章」。任何持有群組金鑰的人(也就是成員)可以來拉、來解密。沒有 fanout、沒有按人頭複製、沒有成員名單。下面用標準密碼學的層面講清楚這套架構。
一條普通的群組訊息在一般 messenger 裡走了哪些步驟
你在 WhatsApp 群組裡發了一條訊息,伺服器大致做這些事:
- 訊息到達伺服器,地址是「ABC 群組」。
- 伺服器查 ABC 群組的成員表 — 假設有 1,000 人。
- 伺服器給這 1,000 個人每人的投遞佇列各 push 一份訊息 envelope。
- 1,000 部手機各自收到一份。
這就是伺服端 fanout:一條入站訊息變成 N 條出站推送。伺服器的 CPU、頻寬、儲存都跟成員數線性相關,再乘上歷史發過的所有群組訊息。人數到幾千之後這道數學就開始算不平了,所以 WhatsApp 死守 1,024 這個數。
BlindPost 的群組是怎麼搭出來的
BlindPost 的一個「群組」,在底層就是一個 X25519 公鑰。僅此而已。我們的伺服器在「群組」那一欄存的,是這個公鑰的 base58 編碼。
你建立一個群組的時候,用戶端:
- 在本機生成一對 X25519 金鑰。
- 把公鑰那一半當作群組的永久識別符。
- 把私鑰那一半,透過端到端加密,分發給你邀請的每個成員。
BlindPost 裡「成員資格」的定義只有一句:誰持有這把群組私鑰,誰就是成員。我們的伺服器上沒有任何「成員名單」。成員的進群退群,本質上是私鑰的進圈出圈 —— 伺服器沒辦法列舉這個圈。
兩層識別符
仔細的讀者可能注意到一個尷尬的推論:既然「群組就是一把 X25519 公鑰」,那這把金鑰一旦輪換(下文會講為什麼要輪換,踢人就是),群組的「密碼學識別符」也跟著變了。從伺服器視角看這沒事;從使用者視角看(「我的群組是不是換 ID 了?」)就有事。
所以每個 BlindPost 用戶端給每個群組維護兩層識別符:
server_group_id—— 密碼學層。等於當前的 X25519 公鑰。金鑰輪換時跟著換。用於訊息定址和路由。local_group_id—— 建群那一刻本機生成的穩定標籤,永遠不變。用在你的對話列表、群組設定、邀請連結,凡是使用者會理解為「群組身分」的地方。用戶端在本機私下維護一份映射:這個穩定標籤 → 當前的密碼學識別符是什麼。
本文其他地方說「群組的公鑰識別符」的時候,指的都是密碼學這一層。穩定的本機標籤純粹是給 UI 用的,讓底層金鑰輪換的時候使用者感知不到任何「群組變了」。
一條群組訊息在 BlindPost 裡走了哪些步驟
你給群組發一條訊息:
- 用戶端臨時生成一對 X25519 金鑰,跟群組的公鑰做 ECDH 算出共享金鑰,加密一次訊息。
- 把一份 envelope 發給伺服器,接收方寫群組的公鑰識別符。
- 伺服器把這份 envelope 存進這個群組的 channel。完事。
沒有 fanout 這一步。你的發送方頻寬是 O(1) —— 不管這個群組是 5 個人還是 5 萬人,你發出去的就這一份。
成員怎麼收到訊息:稀疏拉取(sparse pull)
成員自己決定什麼時候拉。每個成員的用戶端維護一個 cursor — 它在群組 channel 裡讀到哪個 seq 了 — 隔段時間問伺服器一次:「ABC 群組裡 seq N 之後還有新 envelope 嗎?」
這就是稀疏拉取。每個用戶端自己決定:
- 什麼時候問 — app 在前台?每分鐘一次。後台?降頻。手機關機?用戶下次開機再說。
- 拉多少 — 沒什麼落差?跳過。落了五天?抓最新 200 條。新裝用戶端?可能只拉最新 50 條夠看就行。
- 要不要回拉更舊的歷史 — 用戶端自己定。
伺服器把訊息按群組的公鑰識別符索引存碟,問什麼 range 給什麼 range。它不追蹤誰在讀、誰是成員、誰落後了。從伺服器視角看,每次拉取都是「某個用戶端要 ABC 群組 seq N 之後的 envelope」 —— 這個用戶端可能是 10 萬真人裡的一個、可能是一個人五個裝置裡的一台、可能是個無聊的工具隨便拿到公鑰來拉看看。我們無從分辨。
成本結構整個翻了過來:
- 發送方頻寬:每條訊息 O(1)
- 伺服器儲存:每群組 O(訊息數),跟成員數無關
- 伺服器 CPU:push O(1),pull O(1)
- 接收方頻寬:每個成員只拉自己需要的那部分
「成員數」這個值,在我們整套基礎設施裡壓根不存在。
順帶白送的隱私屬性
我們不是主動想藏起群組成員表的 — 我們就沒地方寫。伺服器上根本沒有「群組成員」這張表。「誰在 ABC 群組?」這個問題沒有資料支撐得起來。
傳票最多能從我們這裡拿到的是:「IP A 在某個時間視窗內跟群組公鑰 B 之間有過加密位元組往返」。A 是 B 群組的成員、還是個拿著公鑰亂拉的腳本 — 我們沒辦法告訴你。
代價
天下沒有白吃的午餐:
- 成員必須主動拉。用戶端不醒著的時候不會「收到」訊息。我們在拉取路徑的工程上投入很多(同步區間紀錄、空洞偵測、上滑回填歷史)。
- 沒有伺服端送達回執。伺服器沒法告訴你「50 個人裡 47 個收到了」 —— 它壓根不知道這 50 個是誰。已讀回執這類東西,都是端到端的用戶端之間互傳。
- 沒有伺服端管理工具。踢人、禁言、改角色,全是成員之間簽名信令傳遞;伺服器只能轉發簽名,不能幫你執行封禁。
但這些代價換回來的是:一個不知道你社交圖的伺服器,和一個「人數上限是你手機渲染極限決定的、不是我們叢集分發能力決定的」群組。
BlindPost