從裝置出發打造通訊軟體:local-first、端對端加密與端上搜尋
常見的通訊軟體系統設計預設伺服器擁有聊天紀錄。改成以裝置上的資料為準、伺服器只負責轉送,整份設計要回答的問題就換了一組:身分和金鑰放哪、伺服器看得到什麼、訊息怎麼排序、多台裝置怎麼對齊歷史、搜尋在哪裡做。這篇逐一走過,每一段都對照現有產品公開過的設計。
「設計一個通訊軟體」是 system design 的老題目,常見答案照同一份目錄走:DynamoDB 的 GSI 怎麼切、Kafka 跟 Redis PubSub 選哪個、ZooKeeper 管連線、consistent hashing 找 chat server、每台裝置一個 device ID、靠 sequence number 做多裝置同步。Wondering 平台上一門系統設計課的大綱就是這樣排的。
先決定資料以誰為準
Local-first 這個詞來自 Ink & Switch 2019 年的論文《Local-first software: You own your data, in spite of the cloud》,作者是 Martin Kleppmann 等四人,發表在 Onward! 2019。論文列了七個理想:快、多裝置、離線、協作、長期可用、隱私、使用者掌控。
把它們當成通訊軟體的需求清單,每一項會對應到一個設計決定:
| 理想 | 對通訊軟體的要求 |
|---|---|
| 快(不等網路來回) | 送出先寫本地資料庫,畫面不等伺服器回應 |
| 多裝置 | 每台裝置各存一份完整紀錄 |
| 離線 | 離線可讀可寫,恢復連線後補送 |
| 協作 | 群組裡多人同時發言,合併不需要中央協調 |
| 長期可用 | 服務停了,本地紀錄仍可讀、可匯出 |
| 隱私 | 端對端加密,伺服器看不到內容 |
| 使用者掌控 | 資料格式公開,使用者帶得走 |
最難的是長期可用和使用者掌控。本地紀錄留得住,但只要身分(電話號碼或帳號)和轉送還靠中央伺服器,服務一停就無法再收發,資料格式也由廠商決定。完全去中心化是另一個量級的題目,這篇預設保留一台轉送用的伺服器,其餘盡量放回裝置。
身分與裝置
資料以裝置為準,第一個要定的是身分怎麼認。做法是每台裝置產生自己的 identity key,私鑰不離開裝置;伺服器只維護一份目錄,記錄帳號底下有哪些裝置、各自的公鑰。發訊息時由發送端依收發雙方的裝置清單,各加密一份送出(client-fanout)。
WhatsApp 2021 年公開的多裝置設計是現成的例子。舊架構裡手機是唯一的資料來源,網頁版和桌面版透過一條持續連線鏡像手機畫面,手機斷線,其他裝置就跟著停。新架構讓每台裝置各有 identity key,發送端加密 N 份給 N 台裝置,伺服器只保留帳號對裝置的對照表。
這個架構下,伺服器手上最敏感的東西是公鑰目錄。公鑰由伺服器發,如果它偷換成自己的,端對端加密就失效了。傳統做法是雙方當面掃 QR code 或比對安全碼,但 Meta 算過,100 人的群組要兩兩驗證 4,950 次。WhatsApp 在 2023 年 4 月 13 日公開 key transparency:伺服器維護一份只增不改的 Auditable Key Directory,每次變動都寫進公開的稽核紀錄,用戶端可以自己檢查聯絡人的公鑰是否跟別人看到的一致。實作 akd 以 Rust 開源。
伺服器看得到什麼
伺服器看不到內容,靠的是端對端加密(E2EE)。各家都說自己有加密,實際保證差很多,先把幾個常混用的詞分開。
傳輸加密與端對端加密
傳輸加密(TLS 之類)保護手機到伺服器這一段,伺服器收到後解開、存起來,看得到明文。端對端加密的金鑰只在收發雙方的裝置上,伺服器轉送的是它解不開的密文。Telegram 的一般聊天(cloud chats)屬於前者,訊息存在伺服器、預設沒有 E2EE;只有一對一的 secret chats 是端對端加密,而且綁在雙方各自建立對話的那台裝置上,換裝置就看不到。Durov 替 cloud chats 的設計辯護時說,存在伺服器可以避開第三方不安全的備份,也讓使用者從任何裝置存取訊息。
Forward secrecy
今天的金鑰外洩,不會連帶解開過去的訊息。做法是用短期的 session 金鑰,用完即丟,現在的金鑰推導不回過去的金鑰。Double Ratchet 更進一步,每則訊息換一把。
Post-compromise security
方向相反的保證:裝置被入侵、金鑰外洩之後,只要雙方再做一次新的金鑰交換,之後的訊息又回到安全狀態,也有人叫 future secrecy。Double Ratchet 規格把兩者分給不同元件:對稱金鑰 ratchet 讓過去的金鑰在攻擊者眼中像亂數,但輸入固定,無法從入侵中恢復;Diffie-Hellman ratchet 每輪帶進新的金鑰對,補上恢復能力。
Metadata
E2EE 保護內容,不保護誰在什麼時候跟誰講話。維基百科對 Signal Protocol 的描述直接寫它「不提供匿名性」。前面那份帳號對裝置的目錄,就是伺服器一定會知道的 metadata。
後量子
威脅模型是「現在先錄下密文,等量子電腦成熟再解」。各家的進度:
- Signal 2023 年推出 PQXDH,把初次金鑰交換換成後量子版本。
- Apple 2024 年 2 月 21 日發表 iMessage PQ3,iOS 17.4 上線。除了初次金鑰交換,對話中也定期用 Kyber-768 重新協商(大約每 50 則訊息,至少每 7 天一次)。Apple 把各家分成 Level 0 到 3,PQ3 放在 Level 3,這是廠商自評。
- Signal 2025 年 10 月 2 日發表 Triple Ratchet:原本的 Double Ratchet 旁邊加一條 Sparse Post-Quantum Ratchet(SPQR),用 ML-KEM 768,兩條 ratchet 的輸出一起混進訊息金鑰。ML-KEM 的 encapsulation key 1,184 bytes、ciphertext 1,088 bytes,ECDH 只要 32 bytes,所以 SPQR 用 erasure coding 把大金鑰切成小塊,分散夾帶在多則訊息裡。
群組
群組的成本比一對一高得多。最直觀的是 pairwise fan-out,每個成員各加密一份,前面身分一節的 client-fanout 就是這個思路,成員和裝置一多就線性變貴。另一種是 sender key,每個發送者一把群組金鑰,加密一次大家共用。RFC 9420 的說法是它能做到 forward secrecy,但要達成 post-compromise security,更新成本會隨群組人數的平方成長。
Matrix 的 Megolm 屬於 sender key 這一類:發送端建立一個 outbound session,透過一對一的 Olm 通道把金鑰交給房間裡的每台裝置,之後每送一則就把金鑰做一次雜湊往前推,拿到目前狀態的人讀得到之後的訊息,讀不到之前的。IETF 在 2023 年 7 月發布 Messaging Layer Security(MLS,RFC 9420),用樹狀結構做金鑰封裝,成員推導與更新共用金鑰的成本跟群組人數的對數成正比,適用範圍寫的是「兩人到數千人」。
現有產品怎麼選
協定和性質講完,對照現有產品的公開資訊:
| 軟體 | 協定 | 預設 E2EE | 備註 |
|---|---|---|---|
| Signal | Signal Protocol(PQXDH、Triple Ratchet) | 是 | SPQR 逐步上線,完整強制需要後續更新 |
| Signal Protocol | 是,2016-04-05 起 | 2023 年加入 key transparency;雲端備份的 E2EE 要自己開(2021-10-14 起) | |
| Messenger | Signal Protocol 加 Labyrinth | 個人訊息與通話,2023-12-06 起分批上線 | 歷史訊息加密後存在 Meta 伺服器 |
| iMessage | PQ3 | 是 | iOS 17.4 起 |
| RCS(Google Messages、iPhone) | Google Messages 一對一用 Signal Protocol(2020-11 起);跨平台依 GSMA Universal Profile 3.0 採 MLS | iPhone 端 iOS 26.5 預設開啟,2026-05-11 起以 beta 分批上線 | 需電信商支援;Apple 新聞稿沒寫協定名稱,MLS 出自 GSMA 對 Universal Profile 3.0 的說明 |
| LINE | Letter Sealing v2(ECDH、AES-256-GCM) | 是,2016 年主要用戶端預設開啟,2021 年起無法關閉 | 一對一、50 人以下群組、一對一通話;Open Chat、官方帳號、群組通話不在範圍內,聊天室加入 bot 後也會失去保護 |
| Telegram | MTProto 2.0 | 否 | 只有一對一 secret chats 有 E2EE,群組沒有 |
| Matrix(Element 等) | Olm/Megolm(vodozemac) | 私人對話,2020-05 起 | 規劃以 MSC2883 改用 MLS 做群組加密 |
表上沒列的 Instagram 是反例。Meta 在 2026 年 3 月宣布,Instagram 私訊的端對端加密在 2026 年 5 月 8 日停止支援,給的理由是只有一小部分使用者開啟。這項功能在 Instagram 上一直是選用,想保留加密對話的使用者被引導到預設開啟 E2EE 的 WhatsApp。同一家公司旗下,WhatsApp 和 Messenger 都預設開啟,Instagram 則一直沒有。
LINE 值得多講一點。依 LINE-Break 在 Black Hat 的簡報,台灣約有 2,100 萬人使用,約占人口九成。2017 年多倫多大學 Citizen Lab 與新墨西哥大學的研究者在 FOCI 發表分析,檢驗當時的 6.7.1 版,找到兩個問題:MAC 沒有涵蓋來源、目的地與序號等 metadata,可以重放錄下的訊息;forward secrecy 只做到用戶端到伺服器這段,用戶端之間沒有。LINE 回應時承認這兩點,對後者的理由是要和次要裝置同步。LINE 2021 年的加密白皮書 v2.1 也只把 forward secrecy 寫在用戶端與伺服器之間的傳輸層。
2025 年,丹麥 Aarhus 大學的 Diego F. Aranha 等人發表 LINE-Break(Black Hat Europe 2025,論文收錄於 ASIACCS 2026),對象換成 Letter Sealing v2。他們的結論是金鑰沒有持續輪換,設計上就沒有 forward secrecy;訊息可以被重放、重排或丟棄,兩端都不會察覺;惡意使用者和攻擊者串通時可以冒用發訊者;貼圖與網址預覽洩漏的明文比官方文件寫的多。研究者在 2025 年 6 月 6 日通報,LY 確認了這些發現,回應提到當初為了使用體驗,改以伺服器收到訊息的時間排序,並表示正在研究更新加密協定。
訊息排序:sequence number 要誰來發
常見答案裡的 sequence number,通常由伺服器在訊息進來時遞增指派。好處是全域有序,每台裝置拿到的順序一致;代價是沒有伺服器就排不出順序,離線寫入只能先掛著。前面的 LINE 正是這種做法。
Local-first 的做法是讓每台裝置各自寫,事後合併。合併函數只要滿足交換律、結合律、冪等,任何順序收到、收到幾次,最後狀態都一樣,這就是 state-based CRDT 的條件(Shapiro 等人 2011 年正式定義)。聊天紀錄是最容易套用的結構,先假設訊息只增不改(不處理編輯和收回),合併就是取聯集,再用一個確定的 key 排序。
下面用 Lamport clock 加 device ID 當排序 key,模擬兩台裝置各自離線寫兩則,再互相合併:
from dataclasses import dataclass
from importlib.metadata import version
import platform
import tempfile
from fastembed import TextEmbedding
from qdrant_client import QdrantClient, models
@dataclass(slots=True, frozen=True)
class Message:
lamport: int
device: str
text: str
@property
def key(self) -> tuple[int, str]:
return (self.lamport, self.device)
class DeviceLog:
def __init__(self, device: str) -> None:
self.device = device
self.clock = 0
self.messages: dict[tuple[int, str], Message] = {}
def write(self, text: str) -> None:
self.clock += 1
msg = Message(self.clock, self.device, text)
self.messages[msg.key] = msg
def merge(self, other: "DeviceLog") -> None:
# Union of logs: commutative, associative, idempotent.
self.messages.update(other.messages)
self.clock = max(self.clock, other.clock)
def ordered(self) -> list[Message]:
return sorted(self.messages.values(), key=lambda m: m.key)
phone, laptop = DeviceLog("phone"), DeviceLog("laptop")
phone.write("週六爬山,記得帶雨衣")
phone.write("登山口集合時間改成早上七點")
laptop.write("報稅截止日是五月底")
laptop.write("這次機票訂桃園出發的早班機")
phone.merge(laptop)
laptop.merge(phone)
assert phone.ordered() == laptop.ordered()
for m in phone.ordered():
print(m.lamport, m.device, m.text)
輸出:
1 laptop 報稅截止日是五月底
1 phone 週六爬山,記得帶雨衣
2 laptop 這次機票訂桃園出發的早班機
2 phone 登山口集合時間改成早上七點
兩邊順序一致,assert 通過。但 laptop 和 phone 的訊息交錯排列,跟實際寫入的時間無關。Lamport clock 給出跟因果一致的全序,但不反映實際時間,對聊天來說,離線期間兩邊各自講的話,合併後會互相穿插。由伺服器發 sequence number 可以避開這種怪異感,代價是離線時排不出順序。排序要交給伺服器還是留在裝置,看產品對離線寫入的需求有多重。
多裝置的歷史紀錄
加密和排序都定了,接下來是新裝置加入時,舊訊息從哪來。現有產品在這裡的選擇各不相同:
- Telegram 的一般聊天讓伺服器以它解得開的形式保存,換來任何裝置隨時登入都看得到全部歷史。
- LINE 以次要裝置同步為由,放棄用戶端之間的 forward secrecy(見前文)。
- WhatsApp 讓主裝置把近期聊天加密打包,直接傳給新裝置,資料留在使用者的裝置之間。
- Messenger 用 Labyrinth 把加密後的歷史存在 Meta 伺服器上,金鑰由使用者控制。Meta 列出的難題之一,就是讓使用者不必依賴本地儲存也讀得到歷史。
Forward secrecy 要求舊金鑰用完即丟,歷史紀錄卻要求新裝置事後還能讀到舊訊息,兩件事必須有一邊讓步:舊訊息在某處用一把長期金鑰重新加密保存,或者只能從另一台還有明文的裝置拿。WhatsApp 的裝置連結和 local-first 選後者,以裝置上的資料為準;Messenger 選前者,伺服器只保存解不開的密文。WhatsApp 的雲端備份也屬於前者,只是預設沒有端對端加密,要使用者自己開。
存在伺服器的那份還有法規風險。2025 年 2 月,據報英國政府依《調查權力法》要求 Apple 提供存取 iCloud 加密資料的能力,Apple 隨後停止對英國使用者提供 Advanced Data Protection,既有使用者得在期限內關閉,iCloud 備份等資料回到 Apple 持有金鑰的標準保護。依 The Register 2025 年 2 月 24 日的報導,iMessage 本身的端對端加密不受影響。
從 local-first 的角度看,E2EE 對應的是七個理想裡的隱私,副作用是伺服器能替你做的事變少:它解不開,就沒辦法替你搜尋、合併、建索引,這些工作全落到裝置上。
搜尋只能在裝置上做
本地資料庫做關鍵字比對沒問題,想做語意搜尋(「上次說幾點集合?」)就需要在裝置上放向量索引。Qdrant 有兩條路:
qdrant-client的 local mode:QdrantClient(path=...),在同一個 process 裡跑,資料存在指定資料夾,不需要另外起服務。README 寫的用途是開發、原型和測試。- Qdrant Edge:2025 年 7 月 29 日發表,定位是給機器人、手機、POS、IoT 用的嵌入式向量搜尋,以 library 形式執行,沒有背景 optimizer 或 update thread,所有操作同步、由應用程式控制。發表時是 private beta,目前官方文件標示為 beta,並註明 API 與功能之後可能變動;Python 套件是
qdrant-edge-py,另有 Rust crateqdrant-edge。
接著排序一節的程式,把合併後的訊息用多語 embedding 模型轉成向量,丟進 local mode 搜尋:
# Pin the model name; every device must embed with the same model and version.
MODEL = "sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
embedder = TextEmbedding(MODEL)
docs = phone.ordered()
vectors = list(embedder.embed([m.text for m in docs]))
with tempfile.TemporaryDirectory() as path:
client = QdrantClient(path=path) # in-process, data stays in this folder
client.create_collection(
"chat",
vectors_config=models.VectorParams(
size=len(vectors[0]), distance=models.Distance.COSINE
),
)
client.upsert(
"chat",
points=[
models.PointStruct(
id=i,
vector=v.tolist(),
payload={"text": m.text, "device": m.device, "lamport": m.lamport},
)
for i, (m, v) in enumerate(zip(docs, vectors))
],
)
query = next(iter(embedder.embed(["上次說幾點集合?"]))).tolist()
hits = client.query_points("chat", query=query, limit=2).points
for h in hits:
print(f"{h.score:.3f}", h.payload["text"])
client.close()
print("Python", platform.python_version())
print("qdrant-client", version("qdrant-client"), "fastembed", version("fastembed"))
輸出:
0.538 登山口集合時間改成早上七點
0.390 週六爬山,記得帶雨衣
Python 3.13.12
qdrant-client 1.19.1 fastembed 0.8.1
模型一開始用的是較小的 paraphrase-multilingual-MiniLM-L12-v2(fastembed 標示約 0.22 GB),同一個問題排第一的是「報稅截止日是五月底」(0.404),正確答案排第二(0.322);換成上面約 1 GB 的 paraphrase-multilingual-mpnet-base-v2 才排對。四則訊息說明不了模型好壞。模型要拿自己的資料評估,放到手機上還得在檔案大小和準確度之間取捨。
多裝置各自建索引時,embedding 必須在每台裝置上算出一樣的結果。模型名稱、版本、tokenizer、正規化方式都要鎖住,否則同一則訊息可能在一台搜得到、另一台搜不到。自己寫特徵時也一樣,雜湊要用 hashlib,Python 內建的 hash() 每個 process 的 salt 不同,同一句話在手機和筆電上會算出不同結果。
另一個要決定的是索引同不同步。向量索引是可以從訊息重建的衍生資料,比較簡單的做法是只同步訊息 log,各裝置自己建索引。代價是新裝置連結時要重算一輪,手機上的耗電和時間得量過才知道。
伺服器剩下什麼工作
照這個方向設計,伺服器的職責縮成幾件:
- 裝置目錄:帳號對應哪些裝置、各自的公鑰,加上供用戶端查核的 key transparency 稽核紀錄。
- 信箱:收件裝置離線時暫存加密訊息,送達後刪除。
- 推播喚醒。
開頭那份常見答案裡的 DynamoDB GSI、ZooKeeper、consistent hashing 還用得上,只是服務的對象從聊天紀錄變成待送的加密封包。資料量和保存期限都小很多,設計重點從查詢模式移到連線管理和送達保證。
伺服器省下的複雜度會搬到用戶端:合併邏輯、本地 schema migration、端上索引、裝置遺失後的復原。
補充筆記
- 常見的通訊軟體設計預設伺服器擁有聊天紀錄;改成以裝置為準,伺服器剩下裝置目錄、離線信箱和推播。
- 每台裝置一把 identity key,伺服器保管公鑰目錄,所以需要 key transparency 讓用戶端查核目錄有沒有被偷換。
- 傳輸加密、E2EE、forward secrecy、post-compromise security 是不同的保證;LINE-Break 指出 Letter Sealing v2 設計上沒有 forward secrecy。
- 群組加密的成本差很多:pairwise 線性、sender key 做 post-compromise security 時平方、MLS 對數。
- Lamport clock 加 device ID 讓多裝置合併後順序一致,但順序跟實際時間無關。
- 伺服器解不開的資料只能在裝置上搜尋,embedding 模型要鎖版本,也要拿自己的資料評估。
延伸閱讀
- Wondering〈How to Design WhatsApp〉:系統設計課程,本文只引用公開大綱,當作常見答案的例子。
- Ink & Switch〈Local-first software: You own your data, in spite of the cloud〉:2019 年原始論文,七個理想出自這裡。
- Meta Engineering〈How WhatsApp enables multi-device capability〉:2021 年多裝置架構的一手說明。
- Meta Engineering〈Deploying key transparency at WhatsApp〉:2023-04-13,AKD 的設計與 4,950 次驗證的例子。
- Meta Engineering〈Building end-to-end security for Messenger〉:2023-12-06,Labyrinth 與多裝置歷史的設計說明;預設開啟的公告見〈Launching Default End-to-End Encryption on Messenger〉。
- Help Net Security〈Meta ditches end-to-end encrypted messaging on Instagram〉:2026-03-16,Instagram 停止 E2EE 的日期與 Meta 的理由,屬新聞報導。
- Signal〈The Double Ratchet Algorithm〉:官方規格,forward secrecy 與 break-in recovery 的分工出自這裡。
- Signal〈Signal Protocol and Post-Quantum Ratchets〉:2025-10-02,SPQR 與 Triple Ratchet 的一手說明。
- Apple Security Research〈iMessage with PQ3〉:2024-02-21,Level 分級為 Apple 自評。
- IETF〈RFC 9420: The Messaging Layer Security (MLS) Protocol〉:標準本文,三種群組加密方式的成本比較出自 Introduction。
- Matrix.org〈End-to-End Encryption implementation guide〉:Megolm 的 outbound session 與金鑰分送方式,官方文件。
- Privacy Guides〈Apple Introduces End-to-End Encrypted RCS Messaging in the iOS 26.4 Beta〉:2026-02-19,iOS 26.4 beta 首次出現時的報導,引用 GSMA 對 Universal Profile 3.0 採用 MLS 的說明,二手整理。
- Apple〈End-to-end encrypted RCS messaging begins rolling out today in beta〉:2026-05-11 新聞稿,沒寫明使用的協定。
- Espinoza 等〈Analysis of End-to-End Encryption in the LINE Messaging Application〉:FOCI 2017,同儕審查的學術分析,對象是當年版本。
- Aranha、Hansen、Mogensen〈LINE-Break: Cryptanalysis and Reverse Engineering of Letter Sealing〉:Aarhus 大學,ASIACCS 2026 論文;台灣使用人數、forward secrecy 結論與 LY 回應見 Black Hat Europe 2025 簡報。
- LINE〈LINE Encryption Overview Technical Whitepaper v2.1〉:2021 年 11 月官方白皮書,屬廠商自述;加密報告最新版見〈LINE Encryption Report (2025)〉。
- The Register〈Apple ends iCloud Advanced Data Protection for UK customers〉:2025-02-24,英國 ADP 停用的經過,屬新聞報導。
- Qdrant〈Qdrant Edge: Vector Search for Embedded AI〉:2025-07-29 發表文;目前狀態見〈Qdrant Edge 文件〉,皆屬廠商自述。
- Wikipedia〈Signal Protocol〉、〈Telegram (software)〉、〈Matrix (protocol)〉、〈Conflict-free replicated data type〉:協定組成、時間線、MSC2883 與 CRDT 定義,二手整理,原始出處見各條目註腳。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.12、qdrant-client 1.19.1、fastembed 0.8.1 實測