SimpleX源码深度拆解:3层队列架构如何实现零用户ID的100%隐私通讯

引言:当"你是谁"变成最危险的问题

在Signal里,你的身份是手机号;在Matrix里,你的身份是域名下的用户名;在P2P网络中,你的身份是公钥。几乎所有通讯协议都在某个层面回答了"你是谁"这个问题------而SimpleX Chat的回答是:这个问题本身就不该存在。

SimpleX Chat 是目前唯一一个不存储任何用户标识符 的通讯协议。没有手机号、没有用户名、没有邮箱、甚至没有持久化的公钥身份。它的 GitHub 仓库(simplex-chat/simplex-chat(https://github.com/simplex-chat/simplex-chat))和消息代理(simplex-chat/simplexmq(https://github.com/simplex-chat/simplexmq))共同实现了一套完全基于一次性单向队列的通讯架构。

本文将深入源码层面,拆解 SimpleX 如何通过 3 层队列架构 + Haskell STM 并发模型实现这一目标。

一、核心思想:用队列地址替代用户 ID

传统通讯协议构建的是"用户到用户"的图:

text 复制代码
Alice(手机号:138xxxx) ────> Server ────> Bob(用户名:@bob)

任何能接触到服务端的人都能绘制出完整的社交图谱。这就是元数据泄露------很多时候,知道"谁在和谁通话"比通话内容本身更有价值。

SimpleX 的做法是把这张图拆解为资源到资源的单向管道

text 复制代码
Alice的发送队列 ──> SMP Server A ──> Bob的接收队列
Alice的接收队列 <── SMP Server B <── Bob的发送队列

每个队列都是一次性的、可随时销毁和替换的。 服务端只知道自己管理了若干队列,但不知道哪个队列属于哪个用户,更不知道哪些队列构成了一个"对话"。

下面是源码中 SMP 协议的核心类型定义(来自 simplexmq/src/Simplex/Messaging/Protocol.hs):

haskell 复制代码
-- SMP 协议的命令只有 10 个
data Command (p :: Party) where
  -- 接收方命令
  NEW     :: Command Recipient   -- 创建队列
  SUB     :: Command Recipient   -- 订阅队列
  KEY     :: Command Recipient   -- 绑定密钥
  ACK     :: Command Recipient   -- 确认消息
  OFF     :: Command Recipient   -- 暂停队列
  DEL     :: Command Recipient   -- 删除队列
  -- 发送方命令
  SEND    :: Command Sender      -- 发送消息
  PING    :: Command Sender      -- 心跳检测
  -- 通知
  Ntf     :: Command Recipient   -- 接收通知

-- 服务器响应只有 8 种
data Response where
  IDS     :: Response  -- 队列ID返回
  OK      :: Response  -- 操作确认
  ERR     :: Response  -- 错误
  MSG     :: Response  -- 消息投递
  -- ...

10 条命令、8 种响应------这就是整个传输层协议的完整词汇表。极简的程度堪比 Redis 的 RESP 协议。这种极简设计本身就是安全特性:功能越少,攻击面越小。

二、3 层队列架构详解

SimpleX 的架构分为三个递进层次,每一层都建立在下一层之上:

第 1 层:SMP(SimpleX Messaging Protocol)------单向哑管道

这是最底层的传输协议。SMP 服务器的设计哲学是:做一个一无所知的"哑管道"

关键设计约束:

  • 队列是**单向的**(simplex),消息只能从发送方流入、从接收方流出
  • 服务器之间**绝不通信**------不存在联邦协议
  • 每个队列由一对 Ed448 密钥保护:接收方密钥和发送方密钥
  • 访问授权通过**签名验证**实现,而非用户认证
haskell 复制代码
-- SMP 服务器的核心状态:只存储队列,不存储用户
data ServerState = ServerState
  { queues    :: TVar (Map QueueId QueueState)  -- 队列映射表
  , msgQueue  :: TQueue ServerMessage            -- 内部消息通道
  , subscribers :: TVar (Map QueueId Subscriber) -- 活跃订阅者
  }

-- 注意:没有任何 User 类型,没有任何身份表

第 2 层:SMP Agent------双队列组合器

SMP Agent 负责将两个单向队列组合成一个双向连接。这是关键的一层抽象:

haskell 复制代码
-- Agent 将两个 simplex 队列组合为一个 duplex 连接
data DuplexConnection = DuplexConnection
  { sendQueue    :: SMPQueueInfo    -- 我的发送队列(对方的接收队列)
  , receiveQueue :: SMPQueueInfo    -- 我的接收队列(对方的发送队列)
  , encryptKey   :: PublicKey       -- 端到端加密公钥
  , verifyKey    :: PublicKey       -- 签名验证公钥
  }

-- Agent 管理所有连接
data AgentState = AgentState
  { connections :: TVar (Map ConnId DuplexConnection)
  , msgQueue    :: TQueue AgentMessage
  , e2eKeys     :: TVar (Map ConnId E2EKeyPair)
  }

-- 仍然没有任何用户 ID 字段

Agent 的核心职责:

  • **队列生命周期管理**:创建、轮换、销毁队列
  • **消息加密/解密**:在每个连接上维护 Double Ratchet 状态
  • **重连与故障转移**:当某个 SMP 服务器不可用时,透明切换到备用服务器

第 3 层:Chat Client------用户界面与高层协议

这是用户直接交互的终端层。它负责:

  • 联系人管理(将 `ConnId` 映射为人可读的显示名------仅本地)
  • 群组逻辑(通过多个 `DuplexConnection` 实现)
  • 文件传输、语音通话等高层功能

三、Haskell STM:无锁并发的核心引擎

SimpleX 选择 Haskell 的一个关键原因是其软件事务内存(STM) 模型。源码中大量使用 TVarTMVarTQueueTChan

haskell 复制代码
-- 简化版 SMP 消息投递流程(可运行示例)
import Control.Concurrent.STM
import Control.Concurrent
import qualified Data.Map.Strict as M
import Data.ByteString.Char8 as B

-- | 模拟 SMP 消息队列
data SMPQueue = SMPQueue
  { qId      :: Int
  , messages :: TVar [B.ByteString]  -- STM 保护的消息列表
  }

-- | 投递消息到队列(发送方视角)
sendMessage :: SMPQueue -> B.ByteString -> STM ()
sendMessage q msg = do
  msgs <- readTVar (messages q)
  writeTVar (messages q) (msgs ++ [msg])

-- | 拉取并清空消息(接收方视角)
receiveMessages :: SMPQueue -> IO [B.ByteString]
receiveMessages q = atomically $ do
  msgs <- readTVar (messages q)
  writeTVar (messages q) []
  pure msgs

-- | 演示:两个线程同时操作,STM 保证一致性
main :: IO ()
main = do
  -- 创建队列(等效于 SMP NEW 命令)
  q <- atomically $ do
    let q = SMPQueue 42 (error "TVar not initialized")
    msgs <- newTVar []
    pure $ q { messages = msgs }

  -- Alice 线程:持续发送消息
  _ <- forkIO $ replicateM_ 5 $ atomically $ do
    sendMessage q "Hello from Alice"
    sendMessage q "How are you?"

  -- Bob 线程:延迟后接收消息
  _ <- forkIO $ do
    threadDelay 100000  -- 等待 100ms
    msgs <- receiveMessages q
    putStrLn $ "Bob 收到的消息条数: " ++ show (Prelude.length msgs)
    mapM_ (B.putStrLn . B.append "  -> ") msgs

  threadDelay 500000  -- 等待所有线程完成
  putStrLn "✓ 演示完成:没有用户 ID,只有队列操作"

这段代码展示了 SimpleX 架构中最核心的概念:发送方和接收方通过队列交互,彼此不需要知道对方身份,队列本身也不携带任何身份信息。 STM 保证了并发安全------多个发送方可以同时写入而不会出现竞态条件。

四、加密的洋葱:4 层防护体系

SimpleX 的加密并非只有一层 E2E,而是一个4 层洋葱模型

| 层 | 算法 | 防护目标 |

|---|---|---|

| 传输层 | TLS 1.3(双向认证) | 防中间人篡改 TCP 流 |

| 队列层 | NaCl crypto_box(Curve25519) | 防止 SMP 服务器读取消息内容 |

| 端到端层 | Double Ratchet(Curve448 + X3DH) | 前向安全性 + 后向安全性 |

| 抗关联层 | 额外服务端→接收方加密 | 防止流量关联分析 |

关键洞察:第 2 层(队列加密)和第 4 层(抗关联加密)是 SimpleX 区别于 Signal 等传统 E2E 协议的关键。Signal 只有传输层和端到端层------服务端能看到"Alice 向 Bob 发送了一条消息",只是读不了内容。SimpleX 的服务端连"谁向谁发送了消息"都不知道。

五、队列生命周期:一次对话的全流程

text 复制代码
1. [Bob 创建接收队列]
   Bob → SMP Server: NEW
   SMP Server → Bob: queueId=0x3F2A, recipientKey=Kb1, senderKey=Kb2

2. [Bob 将队列地址分享给 Alice](通过二维码/链接,带外传输)
   地址内容: smp://server-hash::queueId=0x3F2A::encryptKey=Kb2

3. [Alice 发送消息]
   Alice → SMP Server: SEND queueId=0x3F2A, "Hello"
   Alice 用 Kb2 签名以证明自己是合法发送方

4. [Bob 接收消息]
   Bob → SMP Server: SUB queueId=0x3F2A
   SMP Server → Bob: MSG "Hello"
   Bob → SMP Server: ACK (删除服务端消息副本)

5. [队列轮换------每 N 条消息后]
   Bob 创建新队列 queueId=0x7D1E
   通过现有加密通道将新地址发送给 Alice
   旧队列被删除

每一步服务端都只知道孤立的队列操作,不知道这 5 步构成了一个对话。

六、为什么这很重要

2025 年 Signal 被迫关闭瑞典服务器的事件证明了一件事:只要服务端存有用户标识符,无论加密多强,元数据泄露的风险就永远存在。 瑞典政府要求 Signal 在消息中植入后门被拒后,试图通过分析元数据来获取通讯图谱------这正是 SimpleX 从架构层面解决的问题。

SimpleX 的选择是激进的:放弃全局身份,换取绝对的元数据隐私。 代价是用户体验上的小幅妥协(需要通过链接/二维码添加联系人),换来的是技术上可证明的隐私保证------即使 SMP 服务器被完全攻破,攻击者也拿不到社交图谱,因为图谱根本不存在于服务端。

从源码设计的角度看,SimpleX 的价值不仅在于它做了什么,更在于它坚决不做什么

  • 不定义 `User` 类型
  • 不存储任何标识符
  • 不让服务器之间通信
  • 不持久化消息(收到 ACK 即删除)

这种"通过不设计来设计"的思路,是所有注重隐私的系统都应该学习的范式。

参考资料