從裝置出發打造通訊軟體: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備註
SignalSignal Protocol(PQXDH、Triple Ratchet)是SPQR 逐步上線,完整強制需要後續更新
WhatsAppSignal Protocol是,2016-04-05 起2023 年加入 key transparency;雲端備份的 E2EE 要自己開(2021-10-14 起)
MessengerSignal Protocol 加 Labyrinth個人訊息與通話,2023-12-06 起分批上線歷史訊息加密後存在 Meta 伺服器
iMessagePQ3是iOS 17.4 起
RCS(Google Messages、iPhone)Google Messages 一對一用 Signal Protocol(2020-11 起);跨平台依 GSMA Universal Profile 3.0 採 MLSiPhone 端 iOS 26.5 預設開啟,2026-05-11 起以 beta 分批上線需電信商支援;Apple 新聞稿沒寫協定名稱,MLS 出自 GSMA 對 Universal Profile 3.0 的說明
LINELetter Sealing v2(ECDH、AES-256-GCM)是,2016 年主要用戶端預設開啟,2021 年起無法關閉一對一、50 人以下群組、一對一通話;Open Chat、官方帳號、群組通話不在範圍內,聊天室加入 bot 後也會失去保護
TelegramMTProto 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 crate qdrant-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 模型要鎖版本,也要拿自己的資料評估。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.12、qdrant-client 1.19.1、fastembed 0.8.1 實測